---
title: "TypeScript SDK"
description: "Install, authenticate, and drive agents, mail, events, and webhooks from TypeScript — with typed payloads, safe retries, and typed errors."
canonical_url: "https://www.agent-identity.dev/docs/sdk"
markdown_url: "https://www.agent-identity.dev/docs/sdk.md"
last_updated: "2018-10-20"
x_farming_labs_generated_preamble: true
agent:
  tokenBudget: 1000
  task: "Drive the whole API from TypeScript with a typed client, including the vault's client-side encryption."
  outcome: "A configured AgentClient performs agent, mail, event, and vault operations; errors arrive as AidApiError or AidConnectionError rather than untyped failures."
  appliesTo:
    package:
      - "@agentidentity/sdk"
  prerequisites:
    - "Node 20 or newer."
    - "An API key; use an agent key for operations that act as one agent."
  files:
    - "packages/sdk-ts/src/client.ts"
  commands:
    - run: "npm install @agentidentity/sdk"
      description: "Install the client. It has no runtime dependencies."
  sideEffects:
    - "Requests time out after 30s and retry twice by default; a failed POST is never replayed automatically."
  verification:
    - description: "Confirm the client reaches the API."
      expect: "client.status.ready() resolves, which means the API can also reach its database."
  rollback:
    - "The client holds no local state beyond the vault keypair you store yourself, so stopping the process is enough."
  failureModes:
    - symptom: "AidConnectionError with timedOut true."
      resolution: "The request never reached the API. Check baseUrl and network egress, and raise timeoutMs for long-poll waits."
    - symptom: "Vault redemption throws about a malformed envelope."
      resolution: "The private key does not match the identity's registered public key. Use the keypair returned by generateIdentityKey for that agent."
---

# TypeScript SDK
URL: /docs/sdk
LLM index: /llms.txt
Description: Install, authenticate, and drive agents, mail, events, and webhooks from TypeScript — with typed payloads, safe retries, and typed errors.
Related: /docs/getting-started/quickstart, /docs/cli, /docs/api, /docs/concepts/events

<!-- farming-labs:agent-contract:start -->
## Agent Contract

Task: Drive the whole API from TypeScript with a typed client, including the vault's client-side encryption.
Outcome: A configured AgentClient performs agent, mail, event, and vault operations; errors arrive as AidApiError or AidConnectionError rather than untyped failures.

### Applies To

- Package: `@agentidentity/sdk`

### Prerequisites

- Node 20 or newer.
- An API key; use an agent key for operations that act as one agent.

### Files

- `packages/sdk-ts/src/client.ts`

### Commands

- `npm install @agentidentity/sdk` — Install the client. It has no runtime dependencies.

### Side Effects

- Requests time out after 30s and retry twice by default; a failed POST is never replayed automatically.

### Verification

- Confirm the client reaches the API.
  - Expected: client.status.ready() resolves, which means the API can also reach its database.

### Rollback

- The client holds no local state beyond the vault keypair you store yourself, so stopping the process is enough.

### Failure Modes

- AidConnectionError with timedOut true. — Recovery: The request never reached the API. Check baseUrl and network egress, and raise timeoutMs for long-poll waits.
- Vault redemption throws about a malformed envelope. — Recovery: The private key does not match the identity's registered public key. Use the keypair returned by generateIdentityKey for that agent.
<!-- farming-labs:agent-contract:end -->

# TypeScript SDK

`@agentidentity/sdk` is a typed client for the whole API. It converts the API's
`snake_case` wire format to `camelCase`, retries the failures that are safe to retry,
types every event payload, and does the vault's encryption locally so plaintext never
leaves your process. It has no runtime dependencies and needs Node 20+ (or any runtime
with `fetch`).

## Install and connect

```bash title="terminal"
npm install @agentidentity/sdk
```

```ts title="client.ts"
import { AgentClient } from "@agentidentity/sdk";

export const client = new AgentClient({
  baseUrl: "https://api.agent-identity.dev",
  apiKey: process.env.AID_KEY!,
});

await client.whoami(); // { actorType, actorId, orgId, orgSlug, orgName }
```

`whoami()` is the quickest way to confirm a key works and to see whether it is an org key
or an agent key (see [Authentication](/docs/getting-started/authentication)).

| Option | Default | What it does |
| --- | --- | --- |
| `baseUrl` | — | The API origin. A trailing slash is fine. |
| `apiKey` | — | An org key or an agent key. |
| `timeoutMs` | `30000` | Per-attempt deadline. `0` disables it. Long-poll calls set their own. |
| `maxRetries` | `2` | Retries after the first attempt. See [Retries](#retries-and-idempotency). |
| `fetch` | global `fetch` | Inject your own — for tests, proxies, or unusual runtimes. |

## Get a key

Signing up happens before you have a key, so these are free functions rather than client
methods:

```ts title="signup.ts"
import { createOrganization, verifyOwner } from "@agentidentity/sdk";

const org = await createOrganization("https://api.agent-identity.dev", {
  name: "Acme",
  slug: "acme",
  ownerEmail: "you@acme.com", // a six-digit code is emailed here
});

org.adminApiKey; // returned once — store it now

await verifyOwner("https://api.agent-identity.dev", org.adminApiKey, "481923");
```

If a person will also use the dashboard, `signUp` and `logIn` create and resume an
email-and-password session and return the same kind of key. Full walkthrough:
[Quickstart](/docs/getting-started/quickstart).

## Two keys: org and agent

The key you get at signup is an **org key**: it administers everything. Do not hand it to
a running agent. Mint a key scoped to one agent and give the agent that instead:

```ts title="keys.ts"
const agent = await client.identities.create({ handle: "support", displayName: "Support" });

const { key } = await client.apiKeys.create({ name: "support runtime", identityId: agent.id });

const asAgent = new AgentClient({ baseUrl: "https://api.agent-identity.dev", apiKey: key });
```

Some operations only work with the right key — suspending an agent or managing access
rules needs the org key; sending an Agent2Agent task needs the agent's own. A `403` almost always
means the wrong key for the job.

## Agents

```ts title="identities.ts"
const agent = await client.identities.create({ handle: "support", displayName: "Support Bot" });
agent.mailboxAddress; // support@…

await client.identities.list();
await client.identities.get(agent.id);
await client.identities.suspend(agent.id, "investigating abuse report");
await client.identities.reinstate(agent.id);
```

Creating an agent provisions a real mailbox in the same call. Handles are unique per
organization. There is no delete — suspend instead, which stops sending and receiving
without losing history.

## Mail

```ts title="mail.ts"
const message = await client.mail.send({
  identityId: agent.id,
  to: "customer@example.com",     // a string or an array
  cc: ["team@acme.com"],
  subject: "We got your ticket",
  text: "Someone will be with you shortly.",
  html: "<p>Someone will be with you shortly.</p>",
  idempotencyKey: `ticket-${ticketId}`,
});
```

`send` resolves when the message is **accepted**, not delivered. Delivery outcomes arrive
as `mail.delivered`, `mail.bounced`, and `mail.complained` events.

Reading:

```ts title="mail.ts"
const messages = await client.mail.listMessages(agent.id);
const thread = await client.mail.getThread(agent.id, message.threadId);
thread.messages; // the full conversation, oldest first
```

Replying inside a thread — pass the message you are answering:

```ts title="mail.ts"
await client.mail.send({
  identityId: agent.id,
  to: [inbound.from[0].address],
  subject: `Re: ${thread.subject ?? ""}`,
  text: "Thanks — fixed.",
  inReplyToMessageId: inbound.id,
});
```

Attachments are `Uint8Array` bytes:

```ts title="attachments.ts"
import { readFile } from "node:fs/promises";

await client.mail.send({
  identityId: agent.id,
  to: "customer@example.com",
  subject: "Your invoice",
  text: "Attached.",
  attachments: [
    { filename: "invoice.pdf", contentType: "application/pdf", content: await readFile("invoice.pdf") },
  ],
});
```

Drafts (`saveDraft`, `updateDraft`, `sendDraft`, `deleteDraft`), spam flags (`markSpam`,
`unmarkSpam`), and `listAttachments` / `downloadAttachment` round out the namespace. See
[Sending](/docs/mail/sending), [Receiving](/docs/mail/receiving), and
[Attachments](/docs/mail/attachments).

## Events

Every action lands in an append-only log. Waiting on it is what lets an agent sleep until
something happens instead of polling.

### Wait for one event

```ts title="wait.ts"
const { event, timedOut } = await client.events.wait({
  type: "mail.received",
  identityId: agent.id,
  filter: { threadId: message.threadId },
  timeoutMs: 55_000, // the server caps a wait at 55s
});
```

A timeout is a normal result (`timedOut: true`), not an error. A wait matches only events
created **after it starts**; pass `since` (a `Date` or ISO string) to look back.

### Run as a loop

```ts title="agent.ts"
const controller = new AbortController();
process.on("SIGTERM", () => controller.abort());

for await (const event of client.events.subscribe({
  type: "mail.received",
  identityId: agent.id,
  signal: controller.signal,
  onError: (err, attempt) => {
    console.error("poll failed", attempt, err);
    return attempt < 10; // return false to stop instead of retrying
  },
})) {
  event.payload.subject; // typed: string | null
}
```

`subscribe` owns the parts a hand-written loop gets wrong: it advances its cursor using
each event's own `createdAt` rather than your clock, re-polls when the server's window
elapses, and retries failures with exponential backoff. Aborting the `signal` ends the
loop normally, without throwing.

### Typed payloads

`event.payload` is typed per event type. Narrow on `event.type` and the payload narrows
with it — no casts:

| Type | Payload |
| --- | --- |
| `mail.sent` | `messageId`, `threadId` |
| `mail.received` | `messageId`, `threadId`, `from`, `subject` |
| `mail.delivered` · `mail.bounced` · `mail.complained` | `messageId`, `threadId`, `detail?` |
| `a2a.task.created` | `taskId`, `contextId`, `state`, `callerIdentityId`, `caller`, `message`, `messageId`, `parts` |
| `a2a.task.message` | `taskId`, `contextId`, `state`, `caller`, `messageId`, `parts` |
| `a2a.task.canceled` | `taskId`, `contextId`, `state`, `callerIdentityId`, `caller` |
| `a2a.sent_task.updated` | `taskId`, `contextId`, `state`, `result`, `worker`, `messageId`, `parts` |
| `a2a.task.updated` | `taskId`, `contextId`, `state`, `result` |
| `a2a.contact_rule.changed` · `a2a.settings.updated` · `a2a.invitation.*` | audit records of who changed who can reach an agent — see [Access](/docs/a2a/access) |

`client.events.list({ limit, before })` reads recent events newest-first;
`client.events.replay(id)` re-sends one to its webhook endpoints. More in
[Events](/docs/concepts/events).

## A complete agent

```ts title="agent.ts"
import { AgentClient } from "@agentidentity/sdk";

const client = new AgentClient({
  baseUrl: process.env.AID_URL ?? "https://api.agent-identity.dev",
  apiKey: process.env.AID_KEY!,
});

const identityId = process.env.AID_AGENT!;

for await (const event of client.events.subscribe({ type: "mail.received", identityId })) {
  const thread = await client.mail.getThread(identityId, event.payload.threadId);
  const reply = await compose(thread); // your LLM call

  await client.mail.send({
    identityId,
    to: [event.payload.from!],
    subject: `Re: ${thread.subject ?? ""}`,
    text: reply,
    inReplyToMessageId: event.payload.messageId,
    idempotencyKey: `reply-${event.payload.messageId}`,
  });
}
```

Block on the log, read the thread, answer, go around. No polling and no cron. The
idempotency key derives from the inbound message, so a crash and restart never sends the
same reply twice.

## Finding tasks and agents

```ts title="find.ts"
// Tasks: filter, and iterate without touching cursors.
for await (const task of client.a2a.iterateTasks({ direction: "outbound", q: "refund" })) {
  console.log(task.id, task.callerHandle, "→", task.targetHandle, task.state);
}
const page = await client.a2a.searchTasks({ state: "input_required", limit: 20 });
await client.a2a.searchMessages({ role: "agent", q: "which region" });

// Agents: the public directory (no key needed) and your own organization's.
for await (const agent of client.a2a.iterateDirectory({ q: "translation" })) console.log(agent.handle, agent.cardUrl);
await client.a2a.orgDirectory();
```

Each `search…` returns `{ items, nextCursor }`, and each `iterate…` walks the pages for you.
Details: [Tasks](/docs/a2a/tasks), [Messages](/docs/a2a/messages) and [Discover](/docs/a2a/discover).

## One agent's Agent2Agent

`client.a2a.agent()` scopes everything to one agent, addressed by id or handle (with or
without `@`). An agent key can only open its own; an org key can open any of its agents.

```ts title="agent-scope.ts"
const support = client.a2a.agent("@support");

const received = await support.tasks({ state: "input_required" }); // tasks it works on
const sent = await support.sentTasks();                             // tasks it sent

await support.reply(received.items[0]!.id, { intent: "complete", message: "Refund issued." });

const conversations = await support.contexts({ direction: "both" });
await support.renameContext(conversations.items[0]!.id, "Refund for order 4182");

await support.addContactRule({ handle: "@partner-billing", action: "allow" }); // 409 if it already exists
```

Each task carries `caller` and `target` (`identityId`, `organizationId`, `handle`, and
`verified` on the caller) and `completedAt` once it is finished. See
[Conversations](/docs/a2a/conversations) and [Access](/docs/a2a/access).

## Phone

```ts title="phone.ts"
const number = await client.phone.provisionNumber({ identityId: agent.id, state: "NY" });

// Aid Voice answers incoming calls by default. Give it standing instructions and a voice.
await client.phone.setHostedAgentConfig(agent.id, { instructions: "You answer for Acme. Be brief." });
const { voices } = await client.phone.listVoices();

// Send Aid Voice on an errand.
const call = await client.phone.placeCall({
  identityId: agent.id,
  to: "+14155550100",
  mode: "hosted_agent",
  reason: "Confirm tomorrow's 9:30 appointment.",
  onVoicemail: "leave_message",
});

// After it ends: outcome, transcript and follow-ups.
const done = await client.phone.getCall(call.id);
done.outcome;   // "completed" | "no_answer" | "declined" | "failed"
done.followUps; // what Aid Voice recorded for you to do
await client.phone.listTranscript(call.id);
await client.phone.listToolInvocations(call.id);
```

Running your own voice agent instead? Pass `clientWebsocketUrl` and see
[Your agent](/docs/phone/your-agent). Everything else is in [Phone](/docs/phone).

## Calling other A2A agents

`client.a2a.remote()` is a client for the [A2A protocol](/docs/a2a/protocol), so an agent here can
call an agent on this platform or on another vendor's. It reads the target's card and follows it —
JSON-RPC interface, and the credential sent as a Bearer key or in the API-key header the card
names.

```ts title="remote.ts"
const remote = client.a2a.remote({ apiKey: process.env.THEIR_KEY }); // their credential, not ours
const card = "https://api.example.com/a2a/support/card";

const sent = await remote.send(card, { text: "Refund order 4182" });
if (sent.kind === "task") {
  const { task } = await remote.wait(card, sent.task.id);
  console.log(taskText(task)); // the answer, or the question it is waiting on
}
```

With no `apiKey`, it sends your platform key to this platform's own origin and to no one else.

## Webhooks

If you would rather be called than call, register an endpoint:

```ts title="webhooks.ts"
const hook = await client.webhooks.create({
  url: "https://example.com/aid-events",
  identityId: agent.id,             // omit for the whole organization
  eventTypes: ["mail.received"],    // omit for every type
});

hook.secret; // returned once — verify signatures with it
await client.webhooks.listDeliveries({ limit: 20 });
```

Deliveries are signed. See [Webhooks](/docs/concepts/webhooks) for verification and retry
behaviour.

## Contacts and rules

```ts title="contacts.ts"
await client.contactRules.create({ identityId: agent.id, matchAddress: "spammer@example.com", action: "block" });

for (const c of await client.contacts.list({ status: "suggested" })) {
  await client.contacts.approve(c.id);
}
```

Unknown senders become *suggested* contacts automatically; approve or reject them. A
`block` rule stores the message but appends no `mail.received` event. See
[Contacts](/docs/mail/contacts).

## Errors

Two types, so you can tell "the server rejected this" from "I never reached the server" —
they call for different fixes.

```ts title="errors.ts"
import { AidApiError, AidConnectionError } from "@agentidentity/sdk";

try {
  await client.mail.send({ identityId, to: "a@example.com", text: "…" });
} catch (err) {
  if (err instanceof AidApiError) {
    err.status;     // 409 — the API responded
    err.code;       // "idempotency.conflict"
    err.requestId;  // quote this in a bug report
    err.retryable;  // false — do not retry this one
  } else if (err instanceof AidConnectionError) {
    err.method;     // "POST" — the request never arrived
    err.url;
    err.timedOut;   // deadline elapsed, vs. DNS/connection failure
    err.cause;      // the underlying transport error
  }
}
```

Branch on `err.code`, not `err.message`. `retryable` comes from the API rather than being
inferred from the status, so honour it instead of guessing. Cancelling with an
`AbortSignal` throws the usual `AbortError` untouched, so signal-based control flow keeps
working. Both error classes are also importable from `@agentidentity/sdk/errors`.

## Retries and idempotency

Requests time out after 30s and retry twice by default. Retries cover rate limits,
errors the server marks `retryable`, and — for idempotent methods only — network failures
and 5xx. **A `POST` that fails mid-flight is never replayed automatically**, since it may
already have been applied.

That is what `idempotencyKey` is for: with the same key and body, retrying yourself is
safe and returns the original result. Derive it from whatever caused the work (an order
id, an inbound message id), never from a random value generated per attempt.

## Vault

The vault methods are the ones that do real work locally. Secrets are encrypted before
they leave your process; the server never holds a private key.

```ts title="vault.ts"
const keypair = await client.vault.generateIdentityKey(agent.id);
// keypair.privateKey is returned once and never sent anywhere — store it.

const secret = await client.vault.createSecret({ identityId: agent.id, name: "stripe", plaintext: "sk_…" });

const lease = await client.vault.createLease(secret.id, { operation: "charge", ttlSeconds: 60 });
const { secret: plaintext } = await client.vault.redeemLease(lease.leaseId, keypair);
```

See [Vault](/docs/vault).

## Testing

Inject `fetch` to test without a server or the network:

```ts title="agent.test.ts"
const client = new AgentClient({
  baseUrl: "http://test",
  apiKey: "aid_live_test",
  maxRetries: 0,
  fetch: async () =>
    new Response(JSON.stringify({ id: "1", handle: "support", status: "active", mailbox_address: "support@test", vault_public_key: null }), {
      status: 201,
      headers: { "content-type": "application/json" },
    }),
});
```

Remember the wire format is `snake_case`; the client hands your code `camelCase`.

## Everything on the client

| Namespace | Covers |
| --- | --- |
| `client.identities` | `create`, `list`, `get`, `suspend`, `reinstate`, `listSuspensions` |
| `client.mail` | `send` (with `bcc`, `replyAll`), `forward`, `listMessages`, `getThread`, `listThreads`, `listFolders`, `updateMessage`, `updateThread`, `deleteMessage`, `deleteThread`, `search`, `searchOrganization`, drafts, spam flags, attachments |
| `client.events` | `subscribe`, `wait`, `list`, `replay` |
| `client.apiKeys` | `list`, `create`, `revoke` |
| `client.webhooks` | `list`, `create`, `rotateSecret`, `listDeliveries` |
| `client.contacts` · `client.contactRules` | address book and allow/block rules |
| `client.domains` | `add`, `list`, `verify`, `delete`, `deliverability` |
| `client.vault` | keys, secrets, leases |
| `client.tunnels` | `create`, `list`, `get` |
| `client.phone` | numbers, incoming-call action (Aid Voice, your agent, your server, forward, decline), `placeCall`, calls, transcripts, voicemail, `listToolInvocations`, `listVoices`, Aid Voice config |
| `client.a2a` | tasks and conversations, settings, access rules, agent cards, Discover, connect requests, `agent(ref)` for one agent's view, and `remote()` for calling any A2A agent |
| `client.status` | `health`, `ready` |
| `startSignUp()` · `verifySignUp()` · `resendSignUpCode()` | sign up with an emailed code; nothing is created until it comes back |
| `client.whoami()` · `client.claimAccount()` · `client.usage()` | who this key is; attach a login to a bootstrapped org; what has been used against the limits |

Free functions: `createOrganization`, `verifyOwner`, `signUp`, `logIn`, and `verifyWebhook` to check a webhook delivery.

The wire-level reference for everything above is the [REST API](/docs/api) and the
generated [API reference](/api-reference).

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
