# MyAgentMail — Full Documentation Bundle > Single-fetch concatenation of every authoritative MyAgentMail > documentation source. Auto-generated from SKILL.md plus the > reference docs under /skills/myagentmail/references/. Use this when > you want one round-trip instead of fetching each doc separately. --- ## SKILL.md --- name: myagentmail description: | Give AI agents their own real email inboxes AND a LinkedIn intent layer. Send + receive threaded email with custom domains and SMTP/IMAP. Watch LinkedIn for posts that match a plain-English firing rule ("flag founders complaining about cold email") and fire webhooks on matches. Run one-shot historical searches across past 24h / week / month with the same firing rule. Auto-distributes polling across every connected LinkedIn account so daily quota grows with each account the user adds. Use when building agents that need two-way email, intent-based outreach, multi-step campaigns, inline 2FA wait, or multi-tenant SaaS via isolated workspaces. TypeScript SDK (`npm install myagentmail@latest`); raw HTTP works too. --- # myagentmail myagentmail is two products in one API: a **two-way email** infrastructure (real mailboxes with IMAP/SMTP, custom domains, WebSocket events, workspaces) and a **LinkedIn intent** layer (real-time signal watchers + historical searches with classifier-driven firing rules). The same `tk_…` master key works for both. An end-to-end intent-based outreach loop is one product: find the lead via LinkedIn, email them via the inbox API. See the dedicated reference for the LinkedIn surface: - **[references/linkedin.md](references/linkedin.md)** — sessions, intent signals, historical searches, multi-session routing, utilization, recipes for end-to-end outreach. Four API key prefixes, each narrower than the last: | Prefix | Scope | When to use | |---|---|---| | `tk_...` | Tenant master — every workspace in your account | Server-side admin code, dashboard, backend jobs | | `wk_...` | Workspace master — one workspace only | Reseller use: hand to each of your customers | | `ak_...` | Inbox scoped — one inbox only | Agent runtime — least privilege | | `mam_...` | Legacy inbox key | Backwards compat, same scope as `ak_` | **Rule of thumb:** always use the narrowest key that fits. An agent that only needs to read and send from one inbox should hold an `ak_` key, not a `tk_` key. ## Quick start for agents For agents that want a minimum-viable integration, here are three paths ranked by abstraction. **1. MCP server (zero code, works in any MCP-compatible client):** ```bash # In your MCP config (Claude Desktop, Cursor, Windsurf, Cline, etc.): { "mcpServers": { "myagentmail": { "command": "npx", "args": ["-y", "myagentmail-mcp"], "env": { "MYAGENTMAIL_API_KEY": "tk_..." } } } } ``` Exposes ~25 tools spanning inbox + LinkedIn + signals. The agent can call them directly without writing HTTP code. See . **2. TypeScript SDK:** ```typescript import { MyAgentMail } from "myagentmail"; const mam = new MyAgentMail({ apiKey: process.env.MYAGENTMAIL_API_KEY! }); // Provision an inbox + send mail in two calls. const inbox = await mam.inboxes.create({ username: "scout" }); await mam.messages.send(inbox.id, { to: "you@example.com", subject: "first message", text: "hello from a real, deliverable address", }); ``` **3. Raw HTTP:** ```bash # Provision an inbox curl -X POST https://myagentmail.com/v1/inboxes \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "scout" }' # Send a message curl -X POST https://myagentmail.com/v1/inboxes/{INBOX_ID}/send \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "you@example.com", "subject": "hi", "text": "hello" }' ``` The full endpoint catalogue is in the OpenAPI spec at . ## Setup ### Option A: TypeScript SDK (recommended) ```bash npm install myagentmail ``` ```typescript import { MyAgentMail } from "myagentmail"; const client = new MyAgentMail({ apiKey: process.env.MYAGENTMAIL_KEY! }); ``` ### Option B: Raw HTTP (no install) ```typescript const API = "https://myagentmail.com/v1"; const KEY = process.env.MYAGENTMAIL_KEY!; async function mam(method: string, path: string, body?: unknown) { const res = await fetch(`${API}${path}`, { method, headers: { "X-API-Key": KEY, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${await res.text()}`); return res.json(); } ``` ### Option C: Python (raw HTTP) ```python import os, requests API = "https://myagentmail.com/v1" KEY = os.environ["MYAGENTMAIL_KEY"] HEADERS = {"X-API-Key": KEY, "Content-Type": "application/json"} def mam(method: str, path: str, body=None): r = requests.request(method, f"{API}{path}", headers=HEADERS, json=body) r.raise_for_status() return r.json() ``` ### MCP Server (Claude Desktop / Cursor / Windsurf) ```bash npx -y myagentmail-mcp ``` See the [MCP integration guide](https://myagentmail.com/kb#integration-mcp) for config. ### Option D: CLI (the `myagentmail` binary) For long-running agent sessions (Claude Code, terminal-based agents, CI runners) the CLI is **cheaper in agent-token cost** than the MCP server because the command surface is discovered lazily through `--help` instead of loaded eagerly with full tool schemas. Same auth file as the MCP server; pick the surface that fits the runtime. ```bash npm install -g myagentmail-cli # Browser pairing — opens https://myagentmail.com/cli/auth?code=… # in your default browser, you click Approve in the dashboard, the # CLI receives the key directly and writes it to ~/.config/myagentmail/ # credentials. No copy-pasting keys. (For CI use --api-key ; # for headless terminals use --paste.) myagentmail login # Auto-detect every MCP host on the machine (Claude Desktop, Cursor, # Windsurf, Claude Code) and add the MyAgentMail MCP server to each # one's config. Idempotent. The MCP server reads the credentials file # this command's `login` step wrote, so no key handling needed in # host configs. myagentmail mcp install # Health check across email + LinkedIn surfaces. Pre-marketing smoke # test and customer diagnostic. myagentmail doctor # Tunnel inbox events to localhost without ngrok (Stripe-style): myagentmail listen --inbox --forward http://localhost:3000/webhook # Stream inbox events as JSON-per-line for jq pipelines: myagentmail tail | jq 'select(.type == "message.created")' # Also: inboxes list/get/create/delete/send, # linkedin signals list/get/run/matches/deliveries/test-webhook, # mcp install-skill (drops SKILL.md into .claude/skills/). ``` Every command supports `--json` for structured output. Every command supports `--api-key ` to override the configured key for one call. The binary is published as both `myagentmail` and the shorter alias `mam`. **Picking between MCP and CLI:** - *MCP-aware host (Claude Desktop, Cursor, Windsurf, Cline)* — MCP wins on ergonomics. Typed tools, structured outputs, no parsing. - *Long agent session in plain bash (Claude Code, terminal agents, CI)* — CLI wins on token economy. The full surface is one `--help` away, not 5–10k tokens loaded into every turn. - *Local dev with webhook handlers* — CLI wins because of `myagentmail listen` (no ngrok needed). ## Inboxes Each inbox has a stable canonical address (the original `username@domain` it was created with) and can accept mail at additional alias addresses. ```typescript // SDK const inbox = await client.inboxes.create({ username: "scout", displayName: "Scout" }); const { inboxes } = await client.inboxes.list(); const one = await client.inboxes.get(inbox.id); await client.inboxes.update(inbox.id, { displayName: "Scout v2" }); await client.inboxes.delete(inbox.id); ``` ```typescript // Raw HTTP equivalent const inbox = await mam("POST", "/inboxes", { username: "scout", displayName: "Scout" }); const { inboxes } = await mam("GET", "/inboxes"); await mam("PATCH", `/inboxes/${inbox.id}`, { displayName: "Scout v2" }); await mam("DELETE", `/inboxes/${inbox.id}`); ``` ```python inbox = mam("POST", "/inboxes", {"username": "scout", "displayName": "Scout"}) inboxes = mam("GET", "/inboxes") mam("PATCH", f"/inboxes/{inbox['id']}", {"displayName": "Scout v2"}) mam("DELETE", f"/inboxes/{inbox['id']}") ``` The create response includes a one-time `apiKey` (the `ak_` key scoped to this inbox) and IMAP + SMTP credentials. **These are only returned once — store them immediately.** **Credentials are redacted by default.** Plain `POST /v1/inboxes` returns `apiKey`, `imapPassword`, and the `imap.password` / `smtp.password` fields as the literal string `""`. This stops agents that log the create response from leaking the inbox key into logs / chat transcripts. To actually receive the credentials, opt in: ```bash POST /v1/inboxes?reveal=true ``` The response carries a `credentialsRevealed: true|false` boolean so callers can tell whether they have the real values or placeholders. If you forget to set `?reveal=true` and need the apiKey later, the only recovery path is to mint a new one — `POST /v1/inboxes/:id/keys` — since the original is hashed in the DB and not retrievable. ## Messages Send a new message. Pass `verified: true` when you've validated the recipient out-of-band; otherwise sends to unknown addresses are rejected as a deliverability safeguard. ```typescript // SDK — send, reply, reply-all, forward const sent = await client.messages.send(inbox.id, { to: "recipient@example.com", subject: "Quick question", plainBody: "Hey — do you have time to chat this week?", verified: true, }); await client.messages.reply(inbox.id, sent.id, { plainBody: "Thanks — sending a calendar invite now.", }); await client.messages.replyAll(inbox.id, sent.id, { plainBody: "Circling back to everyone ...", }); await client.messages.forward(inbox.id, sent.id, { to: "colleague@example.com", plainBody: "FYI — see thread below.", }); ``` ```python # Raw HTTP — same operations sent = mam("POST", f"/inboxes/{inbox['id']}/send", { "to": "recipient@example.com", "subject": "Quick question", "plainBody": "Hey — do you have time to chat this week?", "verified": True, }) mam("POST", f"/inboxes/{inbox['id']}/reply/{sent['id']}", { "plainBody": "Thanks — sending a calendar invite now.", }) ``` ### Listing and reading ```typescript // SDK const { messages } = await client.messages.list(inbox.id, { direction: "inbound", limit: 20, }); const msg = await client.messages.get(inbox.id, msgId); // also marks read await client.messages.markRead(inbox.id, msgId, false); // mark unread await client.messages.delete(inbox.id, msgId); // soft-delete ``` ## Threads ```typescript // List threads const { threads } = await mam("GET", `/inboxes/${inbox.id}/threads`); // Get every message in a thread, ordered oldest-first const thread = await mam("GET", `/inboxes/${inbox.id}/threads/${threadId}`); ``` ```python threads = mam("GET", f"/inboxes/{inbox['id']}/threads") thread = mam("GET", f"/inboxes/{inbox['id']}/threads/{thread_id}") ``` ## Drafts — iterative composition Drafts let an agent build a message across multiple turns and send in one action. The send endpoint deletes the draft atomically. Drafts with `replyToMessageId` set are threaded as replies when sent. ```typescript // SDK — multi-turn drafting const draft = await client.drafts.create(inbox.id, { to: "ceo@acme.com", subject: "Quick question", }); await client.drafts.update(inbox.id, draft.id, { plainBody: "Hey — noticed you joined Acme last month ...", }); await client.drafts.send(inbox.id, draft.id); // sends + deletes atomically // List / get / delete const { drafts } = await client.drafts.list(inbox.id); await client.drafts.delete(inbox.id, draft.id); ``` ```python # Raw HTTP draft = mam("POST", f"/inboxes/{inbox['id']}/drafts", { "to": "ceo@acme.com", "subject": "Quick question", }) mam("PATCH", f"/inboxes/{inbox['id']}/drafts/{draft['id']}", { "plainBody": "Hey — noticed you joined Acme last month ...", }) mam("POST", f"/inboxes/{inbox['id']}/drafts/{draft['id']}/send") ``` Drafts are the canonical place to hang **human-in-the-loop approval**: the agent produces a draft, a human reviews it in your UI (GET/PATCH), then clicks send (POST `/send`) or discards (DELETE). ## Attachments Attachments on inbound messages are parsed automatically and stored. ```typescript // SDK const { attachments } = await client.messages.listAttachments(inbox.id, msgId); // Download — returns { data: Uint8Array, contentType, filename } const file = await client.messages.downloadAttachment(inbox.id, msgId, attachments[0].id); fs.writeFileSync(file.filename, file.data); // Download raw .eml source (inbound messages only) const eml = await client.messages.downloadRaw(inbox.id, msgId); ``` ```python # Raw HTTP attachments = mam("GET", f"/inboxes/{inbox['id']}/messages/{msg_id}/attachments") r = requests.get( f"{API}/inboxes/{inbox['id']}/messages/{msg_id}/attachments/{attachments['attachments'][0]['id']}", headers=HEADERS, ) data = r.content ``` ## Lists — named address groups Lists are inbox-scoped. Use them for agent-facing audiences ("customers", "active leads") that are too transient to live in your primary database. ```typescript // Create a list const list = await mam("POST", `/inboxes/${inbox.id}/lists`, { name: "active leads", description: "qualified prospects in active outreach", }); // Add entries await mam("POST", `/inboxes/${inbox.id}/lists/${list.id}/entries`, { email: "lead@acme.com", displayName: "Jane Doe", metadata: { source: "linkedin", company: "Acme" }, }); // Get list + all entries const full = await mam("GET", `/inboxes/${inbox.id}/lists/${list.id}`); ``` ## Custom domains Use your own sending domain instead of `@myagentmail.com`. Attach the custom address as an **alias** on your existing inbox — thread history, IMAP credentials, and webhooks all continue to work. ```typescript // SDK const domain = await client.domains.create({ domain: "mail.yourco.com" }); console.log(domain.dnsRecords); // add these at your DNS provider // After adding DNS records, verify const verified = await client.domains.verify("mail.yourco.com"); // Attach as alias on your inbox, promote to primary From address await client.inboxes.addAddress(inbox.id, { email: "scout@mail.yourco.com", primary: true, }); // Old @myagentmail.com address still accepts mail into the same inbox // Zone file export (BIND format) const zone = await client.domains.zoneFile("mail.yourco.com"); // Delete await client.domains.delete("mail.yourco.com"); ``` **Mental model:** one inbox, many addresses. The canonical `username@domain` is immutable and is what IMAP/SMTP authenticates against. One of the accepted addresses is marked primary — that's the outbound `From` header. Changing the primary is cheap; the canonical identity is forever. ## Workspaces — multi-tenant isolation Workspaces isolate inboxes and domains across your customers. One billing relationship with us, many workspaces underneath with scoped keys. ```typescript // SDK const ws = await client.workspaces.create({ name: "Acme Corp" }); const key = await client.workspaces.createKey(ws.id, { name: "Acme agent" }); console.log(key.key); // wk_... — hand to your customer, store now // Your customer uses the wk_ key — sees only their workspace const acmeClient = new MyAgentMail({ apiKey: key.key! }); const acmeInbox = await acmeClient.inboxes.create({ username: "support" }); // This inbox lands in the Acme workspace automatically ``` ```python ws = mam("POST", "/workspaces", {"name": "Acme Corp"}) wk = mam("POST", f"/workspaces/{ws['id']}/keys", {"name": "Acme agent"}) print(wk["key"]) # wk_... ``` Tenant master keys can target a specific workspace by passing `workspaceId` in the body of create calls, or filter lists with `?workspace=`. ## LinkedIn intent — signals + historical search Two distinct surfaces, both keyed on a plain-English **firing rule** that the LLM classifier treats as authoritative: - **Signals** — recurring real-time watcher. Polls past 24 h on a cadence, fires on NEW matches only (dedup'd by post URL). Optional HMAC-signed webhook delivery. Use when you want to be notified the moment LinkedIn lights up. - **Searches** — synchronous one-shot lookup across past-24h | past-week | past-month. Returns the hit list inline (10–15 s typical). Use for backfills, ICP-preview screens, ad-hoc queries. Both run a two-pass classifier: cheap text triage first (drops vendor / recruiter / motivational spam without paying any LinkedIn quota), then profile-aware verification with the author's role + company fetched from a tenant-scoped 7-day cache. ```typescript // One-shot: who in the past month complains about cold email? const { search, results } = await client.linkedin.searches.run({ query: "outbound is broken", intentDescription: "Flag as ready when the author is a founder/operator at a B2B " + "SaaS complaining about cold email or low reply rates. Skip " + "vendors selling outbound tools, agencies, and recruiters.", lookback: "past-month", // sessionId omitted → auto-distribute across all the tenant's // connected LinkedIn accounts. }); // Recurring: poll for new matches every 6h and webhook them to me const { signal } = await client.linkedin.signals.create({ name: "Cold-email complainers", query: "outbound is broken", intentDescription: "...same rule...", cadence: "every_6h", webhookUrl: "https://my-app.com/hooks/intent", filterMinIntent: "medium", }); // signal.webhookSecret returned ONCE — store it, verify HMACs on the // inbound payload with crypto.createHmac("sha256", secret).update(body).digest("hex") ``` **Auto-distribute is the default.** Pass `sessionId: null` (or omit) and polling spreads across every healthy connected account, multiplying daily LinkedIn quota by the number of accounts the user has connected. Pin to a specific session id only if you need outreach to come from a specific profile (e.g. CEO's account). **Per-session daily quotas** (each tenant, each connected account) are visible at `client.linkedin.sessions.utilization()`. When a tenant approaches caps, the right answer is connecting another LinkedIn account, not retrying harder. End-to-end recipe (find lead via signal → email them via inbox API) is in [references/linkedin.md](references/linkedin.md). **Positioning note for agents** — this surface is *intent-based*, not *ICP-list-based*. There is intentionally **no** "find people by title + company + industry" endpoint. Don't try to construct one by chaining calls. The right shape is: - *"Find people talking about X"* → `linkedin.searches.run` or a keyword signal. - *"Find people who engaged with Y"* → engagement signal targeting Y. - *"Find people who switched roles"* → job-change watchlist signal. If the customer truly needs ICP-filtered prospect lists (titles + industries + headcount), compose with **Apollo / Clay / Sales Navigator** to build the cohort, then feed it into a MyAgentMail watchlist or send loop. See "Email enrichment & ICP search" below. ## Email enrichment & ICP search — bring your own provider MyAgentMail does **not** ship an email-enrichment endpoint or an ICP-filtered people-search endpoint. These are deliberate omissions, not oversights: - **Enrichment** is competitive (Apollo, RocketReach, Hunter, Clay, Datagma, FullEnrich) and customers usually already pay one of them. Wrapping a single provider would lock customers in; multi-provider routing is a separate product. Use whichever the customer has a key for. - **ICP people-search** sits in TOS-risky territory on LinkedIn — scraping the people-search surface is a fast path to session bans — and is a thin reseller play against Apollo / Sales Navigator. We intentionally don't proxy it. **The agent recipe** is to compose: build the cohort with a provider the customer already has, then run MyAgentMail's intent + outreach surfaces against it. ```ts // 1. Look up the LinkedIn profile (we own this). const { profile } = await client.linkedin.profiles.lookup({ url: leadUrl }); // 2. Enrich for email — call the customer's provider directly. // No MyAgentMail tool here; the agent calls RocketReach / Apollo / // Hunter via their own MCP server or REST API. const email = await rocketreach.lookupEmail({ linkedinUrl: leadUrl }); // 3. Outreach — we own this end of it. // Note: connections.send takes { sessionId, target, message? } — `target` // is the profile URL, slug, OR ACoA profileId. Pass message ONLY if you // can keep it under 200 BYTES (multi-byte chars count 2-4 bytes each). await client.linkedin.connections.send({ sessionId, target: leadUrl, message: `Hey — saw your post on agent observability. Mind if I send you my notes?`, }); if (email) await client.inbox(inboxId).send({ to: email, subject, body }); ``` Reference implementation in the open-source starter: . When the customer asks "can you find founders at B2B SaaS who complain about cold email", do **not** invent endpoints. Either (a) compose with Apollo for the list + MyAgentMail for outreach, or (b) reframe as an intent query (run a content search on "outbound is broken" with an intent description that filters to founders at B2B SaaS) — the second is usually what the customer actually wanted. ## Real-time events Two intake options — you can use both together. | | WebSocket | HTTP webhook | |---|---|---| | Public URL? | No | Yes | | Retries? | No — push-only while connected | Yes — durable delivery | | Best for | Agent turns waiting inline for a reply | Background workers, logging, CRM sync | - **WebSocket** — see [references/websockets.md](references/websockets.md) - **HTTP webhooks** — see [references/webhooks.md](references/webhooks.md) The flagship WebSocket pattern (receive a 2FA code inline without a public webhook receiver) is covered in the websockets reference. ## Metrics Programmatic counters for dashboards and per-customer usage reports. Workspace master keys are locked to their own workspace; tenant master keys can scope with query params. ```typescript const metrics = await mam( "GET", `/metrics?from=${lastMonthIso}&to=${nowIso}&workspace=${wsId}` ); // { // counts: { sent, received, bounced }, // resources: { inboxes, domains }, // scope: { tenantId, workspaceId, inboxId }, // window: { from, to } // } ``` ## Deliverability rules an agent should follow 1. **Always pass `verified: true`** when you've validated the recipient out-of-band. Unverified sends are rejected unless the recipient has replied to the inbox before. Use `POST /v1/verify-email` as a pre-flight MX check. 2. **Never retry a send on a 5xx without checking.** List recent outbound messages to see if the first attempt succeeded before re-sending. 3. **Use `/reply` (not `/send`) to continue a thread.** The reply endpoints set `In-Reply-To` and `References` correctly; `/send` starts a new thread. 4. **Watch `GET /metrics` for bounce rate.** Inboxes that bounce too much get auto-paused. The fix is always "don't send to dead addresses", not "increase the threshold". 5. **Don't send marketing-style HTML.** Agent mail does best when it's short, conversational, plain-text with minimal HTML. All-caps subjects and tracking pixels trip spam filters. ## Error conventions Every error response has this shape: ```json { "error": "Human-readable message", "code": "MACHINE_READABLE_CODE" } ``` Common codes worth handling: | HTTP | code | Meaning | |---|---|---| | 400 | `VALIDATION_ERROR` | Bad input. `details` has zod field errors. | | 401 | `AUTH_MISSING` / `AUTH_INVALID` | Fix the API key. | | 403 | `AUTH_FORBIDDEN` | Key is valid but not authorized for this resource. | | 403 | `PLAN_LIMIT_REACHED` | Tenant hit an inbox/email/domain cap. Upgrade or wait for next billing period. | | 403 | `INBOX_PAUSED` | Too many bounces — the inbox was auto-paused. Contact support to unpause. | | 403 | `DOMAIN_NOT_VERIFIED` | Custom domain DNS isn't verified yet. | | 403 | `DOMAIN_WRONG_WORKSPACE` | Can't attach a domain that belongs to a different workspace. | | 404 | `NOT_FOUND` | Resource missing or you're not authorized to see it. | | 409 | `CONFLICT` | Resource already exists (e.g. alias already in use on another inbox). | | 429 | — | Rate limited. Back off exponentially. | | 502 | `SMTP_ERROR` / `PROVIDER_ERROR` | Upstream relay or ESP error. Retry with backoff. | ## Endpoint cheatsheet ``` POST /v1/inboxes create GET /v1/inboxes list GET /v1/inboxes/:id detail (with IMAP/SMTP creds) PATCH /v1/inboxes/:id update displayName/metadata DELETE /v1/inboxes/:id delete POST /v1/inboxes/:id/addresses add alias (optionally primary) DELETE /v1/inboxes/:id/addresses/:address remove alias POST /v1/inboxes/:id/send send new message POST /v1/inboxes/:id/reply/:mid reply in-thread POST /v1/inboxes/:id/reply-all/:mid reply-all POST /v1/inboxes/:id/forward/:mid forward GET /v1/inboxes/:id/messages list (filter: direction) GET /v1/inboxes/:id/messages/:mid get (marks read) PATCH /v1/inboxes/:id/messages/:mid mark read/unread DELETE /v1/inboxes/:id/messages/:mid soft-delete GET /v1/inboxes/:id/messages/:mid/raw download raw .eml (inbound) GET /v1/inboxes/:id/messages/:mid/attachments list attachments GET /v1/inboxes/:id/messages/:mid/attachments/:aid download bytes GET /v1/inboxes/:id/threads list GET /v1/inboxes/:id/threads/:tid thread with messages POST /v1/inboxes/:id/drafts create GET /v1/inboxes/:id/drafts list GET /v1/inboxes/:id/drafts/:did get PATCH /v1/inboxes/:id/drafts/:did merge-patch DELETE /v1/inboxes/:id/drafts/:did delete POST /v1/inboxes/:id/drafts/:did/send send (deletes draft) GET /v1/inboxes/:id/lists list POST /v1/inboxes/:id/lists create GET /v1/inboxes/:id/lists/:lid get with entries DELETE /v1/inboxes/:id/lists/:lid delete POST /v1/inboxes/:id/lists/:lid/entries add DELETE /v1/inboxes/:id/lists/:lid/entries/:eid remove POST /v1/domains register (returns DNS records) GET /v1/domains list GET /v1/domains/:domain get GET /v1/domains/:domain/verify re-check DNS + return status GET /v1/domains/:domain/zone-file BIND zone-file export DELETE /v1/domains/:domain delete POST /v1/workspaces create GET /v1/workspaces list GET /v1/workspaces/:id get DELETE /v1/workspaces/:id delete (cascades) POST /v1/workspaces/:id/keys issue wk_ key GET /v1/workspaces/:id/keys list keys DELETE /v1/workspaces/:id/keys/:kid revoke key POST /v1/webhooks create GET /v1/webhooks list GET /v1/webhooks/:id get PATCH /v1/webhooks/:id update url/events/isActive DELETE /v1/webhooks/:id delete GET /v1/ws WebSocket upgrade GET /v1/inboxes/:id/ws legacy per-inbox alias GET /v1/metrics counters (filter: workspace, inbox, from, to) POST /v1/verify-email MX-probe recipient(s) ``` LinkedIn cheatsheet (full reference: [references/linkedin.md](references/linkedin.md)): ``` POST /v1/linkedin/sessions email/password login (may challenge) POST /v1/linkedin/sessions/verify submit PIN POST /v1/linkedin/sessions/poll collect mobile-app approval POST /v1/linkedin/sessions/import import li_at + JSESSIONID cookies GET /v1/linkedin/sessions list DELETE /v1/linkedin/sessions/:id revoke GET /v1/linkedin/sessions/utilization per-session 24h quota usage POST /v1/linkedin/profiles/lookup resolve URL → profileId + headline body: { sessionId, profileUrl } returns { ok, profile: { name, profileId, publicIdentifier, headline, ... } } 404 PROFILE_NOT_FOUND on slug mismatch (strict slug match — does NOT silently return a related profile). POST /v1/linkedin/connections send connection request body: { sessionId, target, message? } `target` = profile URL, slug, or ACoA... profileId. `message` ≤ 200 chars AND ≤ 200 BYTES — multi-byte chars (em-dash, smart quotes, emoji) count as 2-4 bytes each. Use ASCII alternatives or shorten if the byte limit trips on otherwise-fine text. POST /v1/linkedin/search/content raw post search (no classifier) body: { sessionId, keyword, datePosted?: past-24h|past-week|past-month|any, count?: 1-25 } POST /v1/linkedin/searches historical search w/ firing rule (sync) GET /v1/linkedin/searches list past searches GET /v1/linkedin/searches/:id re-open past search (no quota) POST /v1/linkedin/signals create recurring intent watcher GET /v1/linkedin/signals list GET /v1/linkedin/signals/:id get PATCH /v1/linkedin/signals/:id update / pause DELETE /v1/linkedin/signals/:id delete POST /v1/linkedin/signals/:id/run force-poll now GET /v1/linkedin/signals/:id/matches historical matches GET /v1/linkedin/signals/:id/deliveries webhook delivery log POST /v1/linkedin/signals/:id/test-webhook fire synthetic test event POST /v1/linkedin/messages send a DM. Recipient must be a 1st-degree connection (LinkedIn rejects non-1st sends — the upstream error surfaces verbatim). body: { sessionId, text, recipient? OR conversationUrn? } `recipient` (preferred) is a profile URL, slug, ACoA… profileId, or full URN — the backend resolves and does find-or-create on the 1:1 thread. `conversationUrn` appends to a known thread you already have an URN for (advanced). Returns { ok, messageUrn, conversationUrn, backendConversationUrn, deliveredAt }. Quota: 1 send_message unit per call (50/day default). GET /v1/linkedin/conversations?sessionId=… list inbox — the most-recent ~20 conversations with participants, unread counts, last-message previews, and the conversationUrn agents pass to /messages. GET /v1/linkedin/conversations/:conversationUrn/messages?sessionId=… read messages in one thread, most-recent first. URL- encode the conversationUrn. Returns text + sender info + isFromViewer + attachment/ reaction counts. ``` **Diagnosing a zero-result search.** `POST /v1/linkedin/searches` returns a `diagnostics` block alongside `search` and `results` so you can tell *why* a search returned 0 hits: ```json { "search": { ... }, "results": [], "diagnostics": { "candidatesExamined": 10, "candidatesWithEmptyBody": 10, // LinkedIn returned posts but their bodies were empty "rejectedByTextTriage": 0, "rejectedByProfileLookup": 0, "rejectedByMinIntent": 0, "scraperHealthHint": "scraper_returned_empty_bodies" // tells you the scraper is the suspect, not your rule } } ``` `scraperHealthHint` values: `ok`, `scraper_returned_no_posts` (LinkedIn served nothing), `scraper_returned_empty_bodies` (LinkedIn served post shells but the RSC encoding changed and we couldn't extract text — file this as a bug; it's not your rule's fault), `scraper_call_failed`. Full machine-readable spec at . Per-endpoint reference is paired HTML + markdown: - **HTML for humans** — `https://myagentmail.com/docs//` (e.g. ). - **Markdown for agents** — `https://myagentmail.com/docs-md//` (e.g. ). Same content as the HTML page with no chrome. **Prefer this** when you need one endpoint's schema + code samples without parsing YAML or HTML. Long-form concept guides at . --- ## references/linkedin.md # LinkedIn — sessions, signals, searches, multi-session routing This reference covers the full LinkedIn surface of the myagentmail API. Read after the SKILL.md if you're building any of: - a real-time intent watcher (Gojiberry-style) - a one-shot ICP discovery / preview - agentic outreach that emails the matched lead - automation that respects per-account LinkedIn rate limits The same `tk_…` master key works for everything below. Inbox-scoped `ak_…` keys can also call most LinkedIn endpoints — confirm via the key's permissions. ## Mental model — three things that look similar but aren't 1. **`POST /v1/linkedin/search/content` (raw search)** — single-shot keyword search returning posts as LinkedIn ranks them. No classifier, no firing rule, no profile enrichment. Fast and free of LLM cost. Use it when you want raw results to feed your own pipeline. 2. **`POST /v1/linkedin/searches` (historical search w/ firing rule)** — same underlying LinkedIn data, but every result runs through the two-pass classifier with the customer's plain-English firing rule applied. Persisted server-side so re-opening a past search is free. Use this when you want buyer-intent quality, not raw breadth. 3. **`POST /v1/linkedin/signals` (recurring watcher)** — a forever- running version of #2 that polls every N hours, fires on NEW matches only (dedup by post URL), and optionally HMAC-signs webhooks to your endpoint. Use this when the user's job is "tell me when someone matches my ICP," not "find me everyone who already did." If you're picking between #2 and #3: **#3 is for the marketing funnel** ("hot leads in real time"), **#2 is for the demo / discovery flow** ("show me 50 examples right now"). Most apps need both. ## Connect a LinkedIn account Three options. The dashboard widget calls the same endpoints under the hood — agents use the API directly. ```typescript // Option A: email + password (handles 2FA challenges) const result = await client.linkedin.sessions.create({ email: "user@example.com", password: "...", label: "Founder account", }); // May return { ok: false, challenge: true, challengeId, challengeType: "pin" | "mobile" } // → call .verify({ challengeId, pin }) for PIN, .poll({ challengeId }) for mobile-app approval // Option B: import existing cookies the user extracted themselves await client.linkedin.sessions.import({ liAt: "AQE...", jsessionId: "ajax:...", label: "CEO account", }); // Option C: drop-in React widget (browser-side) // import { LinkedInConnect } from "@myagentmail/react" // ``` Cookies are AES-256-GCM encrypted at rest. Each session is tenant- scoped. The customer's account never leaves their connected session — we never use shared scrape farms or rotating proxies. ## Auto-distribute (the default; almost always correct) Every signal and search accepts an optional `sessionId`. Pass `null` or omit it and the **session router** picks the least-utilized healthy session at every action — search, profile lookup, connection request, message send. Each connected LinkedIn account multiplies the tenant's daily LinkedIn quota. ```typescript // Auto — the recommended default. await client.linkedin.signals.create({ name: "...", query: "...", intentDescription: "...", // sessionId omitted == null == auto-distribute }); // Pin — only when you need outreach to come from a specific profile // (e.g. the CEO's account for personal-feeling outreach). await client.linkedin.signals.create({ name: "...", query: "...", intentDescription: "...", sessionId: "ses_specific_id", }); ``` You will see most agents incorrectly try to "smart-pick" a session by listing sessions and choosing the first one. Don't do that — it recreates the pinned-mode pathology. Pass `null` and let the router pick. ## Firing rule — the most important field The firing rule is a **plain-English description** the LLM classifier treats as authoritative. The keyword (`query`) is just a coarse LinkedIn-side pre-filter. A good firing rule names: 1. **Who fires** — the role, seniority, company stage that should match. 2. **What signal** — the pain or behavior in the post that should match. 3. **Who explicitly does NOT fire** — vendors / agencies / recruiters / content marketers selling the same thing the buyer would buy. ```text Flag as ready when the author is a founder, CEO, or head-of-sales at a B2B SaaS company between Seed and Series B, complaining about cold email being broken, low reply rates, or the team being burned out on outbound. Skip vendors selling outbound tools, agencies offering "done-for-you outbound", recruiters posting job ads, and content marketers / coaches writing thought-leadership posts. ``` The negatives are non-optional. Without them the classifier surfaces every "I help SaaS founders 3x their outbound" content marketer imitating your customer's pain. ## Run a one-shot historical search Synchronous; returns 10–15 s typical. Persisted so re-opening is free. ```typescript const { search, results } = await client.linkedin.searches.run({ query: "outbound is broken", intentDescription: "...", lookback: "past-month", // past-24h | past-week | past-month minIntent: "medium", // optional classifier floor limit: 50, // 1-100, default 50 }); // search.id → re-fetch later with .get(id) — no LinkedIn quota spent // search.tookMs, search.resultCount, search.errorCode (if any) for (const r of results) { // r.author.name / r.author.role / r.author.company ← verified // r.author.profileUrl // r.postUrl, r.postExcerpt // r.classification.intent: "high" | "medium" | "low" // r.classification.reason: one sentence citing evidence from the post // r.triageScore: 0-100 (Pass-1 confidence) } ``` `r.author.role` and `r.author.company` are populated when the upstream profile lookup succeeded. Null when it was deferred (per-poll profile-lookup cap reached) — uncommon for searches because they have a higher cap than recurring polls. ## Create a recurring intent watcher ```typescript const { signal } = await client.linkedin.signals.create({ name: "Cold-email complainers", query: "outbound is broken", intentDescription: "...", cadence: "every_6h", // daily | every_12h | every_6h | manual webhookUrl: "https://my-app.com/hooks/intent", filterMinIntent: "medium", // webhook delivery floor; matches at // or above this intent fire the webhook }); // signal.webhookSecret is returned ONCE on create. Store it. // HMAC-SHA256(secret, raw_request_body) === header X-MyAgentMail-Signature ("v1=") ``` Polls run server-side on a 5-minute cron. Each poll: 1. Picks a healthy session via the router (or the pinned one). 2. Searches LinkedIn for the keyword over the past 24 h. 3. **Pass-1 triage** (cheap text-only classifier) drops obvious rejects without paying any LinkedIn quota. 4. **Pass-2 verify** — for triage-pass posts (capped per poll), fetches the author's profile from cache or via session-router- selected profile lookup, runs the full classifier with role + company as authoritative context. 5. New matches that pass the customer's `filterMinIntent` fire a webhook delivery (HMAC-signed, exponential retry up to 5 attempts over ~12 h). Posts that pass triage but blow the per-poll profile-lookup budget are stored with `pendingEnrichment: true` and the next poll picks them up. They don't fire webhooks until enrichment completes — we don't ship text-only false positives. ## Webhook payload shape ```json { "type": "signal.match", "signal": { "id": "...", "name": "...", "query": "..." }, "match": { "id": "...", "foundAt": "2026-04-27T..." }, "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:...", "excerpt": "...", "postedAt": null }, "author": { "name": "Jane Smith", "profileUrl": "https://www.linkedin.com/in/jane-smith/", "headline": "Founder & CEO at Acme", "role": "Founder & CEO", "company": "Acme" }, "classification": { "engage": true, "intent": "high", "reason": "The author writes 'our outbound is dead' — direct match for the firing rule's complaint pattern." } } ``` Verify the signature before trusting the body: ```typescript import crypto from "node:crypto"; export async function POST(req: Request) { const body = await req.text(); // raw, before JSON.parse const sig = req.headers.get("x-myagentmail-signature") || ""; const expected = "v1=" + crypto .createHmac("sha256", process.env.SIGNAL_WEBHOOK_SECRET!) .update(body) .digest("hex"); if (sig !== expected) return new Response("bad signature", { status: 401 }); const event = JSON.parse(body); if (event.type !== "signal.match") return new Response("ok"); // ... your per-match logic } ``` ## All three signal kinds share one endpoint This is the most common point of confusion: `keyword`, `engagement`, and `job_change_watchlist` are **not** separate endpoints. They are all `POST /v1/linkedin/signals`, discriminated by the `kind` field in the JSON body. ``` POST /v1/linkedin/signals → create any kind (kind in body) GET /v1/linkedin/signals → list all kinds GET /v1/linkedin/signals/:id → fetch one PATCH /v1/linkedin/signals/:id → update DELETE /v1/linkedin/signals/:id → remove POST /v1/linkedin/signals/:id/run → force-run a poll GET /v1/linkedin/signals/:id/watchlist → list entries (watchlist signals only) POST /v1/linkedin/signals/:id/watchlist → add entries DELETE /v1/linkedin/signals/:id/watchlist/:entryId → remove an entry ``` The body shape per kind: | `kind` | Required body fields | |---|---| | `keyword` | `name`, `query`, `intentDescription` | | `engagement` | `name`, `target: {kind, url}`, `intentDescription` | | `job_change_watchlist` | `name`, `watchlist: [{profileUrl}, ...]`, `intentDescription` | Common optional fields across all kinds: `cadence`, `webhookUrl`, `filterMinIntent`, `sessionId`. ## Engagement signals — watch a person or company for engagers The keyword signal asks "who is talking about X?" An **engagement signal** asks "who is engaging with this specific person/company's posts?" — a much narrower, much higher-signal funnel. Use it to: - Track competitor company pages: every reactor or commenter on Acme Inc.'s posts is a hand-raised lead for Acme's category. - Track influencer profiles in your niche: their engagers self-select as your ICP. - Track a champion's posts at a target account: the engagers are warm intros into the same company. Mechanics: 1. We resolve the actor URL → `urn:li:fsd_profile:...` or `urn:li:fsd_company:...` at create time. Profile = `/in/{slug}`, company = `/company/{slug}`. 2. Each poll fetches the actor's recent posts since `lastSeenPostUrn` (first poll caps at 14 days). 3. For every new post we list reactors + commenters, dedup engagers across posts (comment beats reaction so we keep the verbatim comment text), triage cheap, profile-cache verify the survivors, and run the firing rule against each engager's role + company + headline. 4. We fire `signal.engagement` webhooks per matched engager. The `engager.commentText` is the killer field — quote it back in your outreach. ```typescript await client.linkedin.signals.createEngagement({ name: "Acme company-page engagers", target: { kind: "company", url: "https://www.linkedin.com/company/acme/" }, intentDescription: "Flag engagers who are heads-of-sales / RevOps / VPs at SaaS " + "companies between Series A and C — skip Acme employees, " + "competitors, and recruiters.", cadence: "every_12h", webhookUrl: "https://your-app.com/hooks/engagement", filterMinIntent: "medium", }); ``` Equivalent raw HTTP (use this if you're not on the TypeScript SDK): ```bash curl -X POST https://myagentmail.com/v1/linkedin/signals \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "engagement", "name": "Acme company-page engagers", "target": { "kind": "company", "url": "https://www.linkedin.com/company/acme/" }, "intentDescription": "Flag engagers who are heads-of-sales / RevOps / VPs at SaaS companies between Series A and C — skip Acme employees, competitors, and recruiters.", "cadence": "every_12h", "webhookUrl": "https://your-app.com/hooks/engagement", "filterMinIntent": "medium" }' ``` `signal.engagement` payload: ```json { "type": "signal.engagement", "signal": { "id": "...", "name": "...", "kind": "engagement" }, "match": { "id": "...", "foundAt": "2026-04-27T..." }, "target": { "kind": "company", "url": "https://www.linkedin.com/company/acme/", "label": "Acme Inc." }, "post": { "url": "https://www.linkedin.com/feed/update/urn:li:activity:.../", "excerpt": "We shipped real-time replay this morning…", "postedAt": null }, "engager": { "name": "Jane Smith", "profileUrl": "https://www.linkedin.com/in/jane-smith/", "headline": "VP RevOps at Beta", "role": "VP RevOps", "company": "Beta", "action": "commented", "commentText": "We're hitting this exact problem at Beta — DM'd you." }, "classification": { "engage": true, "intent": "high", "reason": "..." } } ``` Cost: one engagement-poll session-action per cycle, plus profile lookups for the top-N triage-pass engagers (capped at `PROFILE_LOOKUPS_PER_POLL`, default 5). Per-session daily budget for engagement-poll is 50. ## Watchlist signals — track job changes for people you care about Job changes are the cleanest non-content buying signal on LinkedIn — "VP Eng moved from Beta to Acme" creates two warm-intro opportunities inside Acme on day one. A **watchlist signal** is a list of profile URLs we poll weekly; we fire only when role/company changes AND the firing rule matches the new role. ```typescript await client.linkedin.signals.createWatchlist({ name: "Champions that left the gate keepers", profileUrls: [ "https://www.linkedin.com/in/jane-doe-eng/", "https://www.linkedin.com/in/john-smith-pm/", // …up to 500 per signal ], intentDescription: "Fire when this person's NEW role is at a SaaS company between " + "$5M and $100M ARR and the title implies budget over outbound or " + "sales tooling. Skip moves into agencies and consultancies.", webhookUrl: "https://your-app.com/hooks/job-change", filterMinIntent: "medium", }); ``` Equivalent raw HTTP. Note that `watchlist` is an array of objects (not strings) — each entry is `{profileUrl, label?}`: ```bash curl -X POST https://myagentmail.com/v1/linkedin/signals \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "job_change_watchlist", "name": "Champions that left the gate keepers", "watchlist": [ { "profileUrl": "https://www.linkedin.com/in/jane-doe-eng/" }, { "profileUrl": "https://www.linkedin.com/in/john-smith-pm/" } ], "intentDescription": "Fire when this person's NEW role is at a SaaS company between $5M and $100M ARR and the title implies budget over outbound or sales tooling.", "webhookUrl": "https://your-app.com/hooks/job-change", "filterMinIntent": "medium" }' ``` Watchlist polls walk entries oldest-polled-first; each entry is poll-rate-limited via the shared profile-lookup cache (7-day TTL, 30-min same-slug dedup across sessions). The per-session `profile_lookup` budget (30/day) caps how many entries can be refreshed in a cycle, so per-entry cadence ends up around weekly for a 200-entry watchlist on a 1-account tenant. First poll for an entry is a SNAPSHOT — we record the role/company without firing. Subsequent polls diff and fire. `signal.job_change` payload: ```json { "type": "signal.job_change", "signal": { "id": "...", "name": "...", "kind": "job_change_watchlist" }, "match": { "id": "...", "foundAt": "2026-04-27T..." }, "person": { "name": "Jane Doe", "profileUrl": "https://www.linkedin.com/in/jane-doe-eng/", "headline": "VP Engineering at Acme" }, "change": { "oldRole": "Director of Engineering", "oldCompany": "OldCo", "newRole": "VP Engineering", "newCompany": "Acme" }, "classification": { "engage": true, "intent": "high", "reason": "..." } } ``` Manage the list anytime: ```typescript await client.linkedin.signals.watchlist.list(signalId); await client.linkedin.signals.watchlist.add(signalId, [ "https://www.linkedin.com/in/new-target/", ]); await client.linkedin.signals.watchlist.remove(signalId, entryId); ``` Equivalent raw HTTP: ```bash # List watchlist entries curl https://myagentmail.com/v1/linkedin/signals/$SIGNAL_ID/watchlist \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" # Add entries (note: same {profileUrl} object shape as create) curl -X POST https://myagentmail.com/v1/linkedin/signals/$SIGNAL_ID/watchlist \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "entries": [{ "profileUrl": "https://www.linkedin.com/in/new-target/" }] }' # Remove a single entry curl -X DELETE https://myagentmail.com/v1/linkedin/signals/$SIGNAL_ID/watchlist/$ENTRY_ID \ -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" ``` ## LinkedIn — Posts (read-only) Three GET endpoints over the post-graph helpers the engagement signal runner uses internally. Surfaced for one-shot agent workflows ("show me Acme's last 5 posts and who reacted") that don't warrant a recurring signal. ```bash # Recent posts by a profile or a company curl "https://myagentmail.com/v1/linkedin/posts/by-author?\ sessionId=$SID&profileUrl=https://www.linkedin.com/in/jane-doe/&count=10" \ -H "X-API-Key: $MYAGENTMAIL_API_KEY" # Commenters on a post (URL-encode the URN) URN=$(printf %s "urn:li:activity:7332661864792854528" | jq -sRr @uri) curl "https://myagentmail.com/v1/linkedin/posts/$URN/comments?sessionId=$SID&count=20" \ -H "X-API-Key: $MYAGENTMAIL_API_KEY" # Reactors on a post — returns reactionType (LIKE / CELEBRATE / SUPPORT / ...) curl "https://myagentmail.com/v1/linkedin/posts/$URN/reactions?sessionId=$SID&count=100" \ -H "X-API-Key: $MYAGENTMAIL_API_KEY" ``` Quota: each call burns one `engagement_poll` slot from the per-session daily budget. LinkedIn's per-call rate-limiter cares about call count not response size, so large `count` values are no more expensive than small ones. Post-write actions (create post, like, comment) are not yet exposed — HAR captures pending. Track in the LinkedIn HAR backlog. ## LinkedIn — Inbox signals (connection_accepted + message_received) Two new signal kinds layer on top of the existing signal-runner → signal-matches → signal-deliveries pipeline. Same webhook delivery, same `UNIQUE(signal_id, post_url)` dedup. Both are **session-scoped** — they require a pinned `sessionId` because the inbox + 1st-degree network belong to one LinkedIn account. ```bash # Fire when a new connection appears in the 1st-degree network. curl -X POST https://myagentmail.com/v1/linkedin/signals \ -H "X-API-Key: $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "connection_accepted", "name": "New connections — primary account", "sessionId": "sess_abc", "cadence": "every_6h", "webhookUrl": "https://your-app.com/hooks/linkedin", "intentDescription": "Notify on every new 1st-degree connection." }' # Fire on every new not-from-viewer message in the inbox. curl -X POST https://myagentmail.com/v1/linkedin/signals \ -H "X-API-Key: $MYAGENTMAIL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "message_received", "name": "Inbox — primary account", "sessionId": "sess_abc", "cadence": "every_6h", "webhookUrl": "https://your-app.com/hooks/linkedin", "intentDescription": "Notify on every inbound DM." }' ``` The webhook payload includes: - `match_kind` — `"connection"` for connection_accepted matches, `"message"` for message_received matches. - `connection_attribution` (connection matches only) — `"outbound"` if we previously sent the new connection an invitation (we know because the route handler records every successful `POST /v1/linkedin/connections` to `linkedin_invitations_sent`); `"inbound"` if the connection was a cold add. Unipile's API doesn't surface this; ours does. Polling uses random jitter, not fixed cron — Unipile's design, because LinkedIn flags accounts whose access pattern looks too clockwork. The `every_6h` cadence is the fastest auto-cadence we offer; it means up to ~6h latency between LinkedIn-side event and webhook fire. **Note-bearing-invite shortcut.** When a customer sends an invitation WITH a personalized note and the invitee accepts, LinkedIn auto-creates a 1:1 chat with the original note as the first message. The `message_received` runner notices this and fans out to any `connection_accepted` signals on the same session — giving a realtime-ish acceptance path without waiting for the slower connections-list diff. Documented in Unipile's docs as "real-time method (invitations with messages only)." **First-poll bootstrap.** A brand-new signal records every existing connection / message in its first run WITHOUT firing webhooks — otherwise the customer would get one webhook per existing item in their network or inbox. Subsequent polls fire only on genuinely new entries. ## End-to-end recipe — find a lead and email them The whole intent-based outreach loop in six API calls: ```typescript // 1. Connect a LinkedIn account (one-time, via widget or .sessions.create) // 2. Capture the user's ICP as a firing rule (your UI) // 3. Create the signal — auto-distribute mode const { signal } = await client.linkedin.signals.create({ name: user.companyName + " — buyer intent", query: user.keyword, intentDescription: user.firingRule, cadence: "every_6h", webhookUrl: `https://${user.subdomain}.yourapp.com/hooks/intent`, filterMinIntent: "medium", }); await db.users.update(user.id, { signalSecret: signal.webhookSecret }); // 4. (Optional) one-shot preview screen — show the user 25 hits before // they pay, so they trust the rule const seed = await client.linkedin.searches.run({ query: user.keyword, intentDescription: user.firingRule, lookback: "past-month", minIntent: "medium", limit: 25, }); // 5. On webhook arrival (your endpoint at /hooks/intent): // verify signature → use the verified author.role + author.company // + post excerpt to compose a personalized opener with your LLM → // send via myagentmail const opener = await llm.compose({ task: "personalized_opener", post: event.post, author: event.author, // role + company are authoritative reason: event.classification.reason, seller: { product: "...", positioning: "..." }, }); await client.messages.send(process.env.SENDER_INBOX_ID!, { to: resolvedEmail, // your enrichment provider resolves // LinkedIn profile → email subject: opener.subject, plainBody: opener.body, verified: true, }); // 6. (Optional) follow-up sequence — schedule a check after N days, // if no reply, send a different-angle follow-up via the messages // API. The starter repo's signal-runner handles this pattern. ``` ## Per-session quota visibility ```typescript const { sessions, budget } = await client.linkedin.sessions.utilization(); // budget = { search_signal: 50, search_history: 50, profile_lookup: 30, ... } // per session: { sessionId, label, counts: { profile_lookup: 17, ... }, // remaining: { profile_lookup: 13, ... }, // rateLimitedUntil, status } ``` Use this to: - Decide whether to retry now or back off. - Surface a "connect another LinkedIn account to add headroom" nudge in your UI when `remaining.profile_lookup < 5` for every session. - Pick the healthiest session yourself if you want pinned mode but also want resilience (rare — auto-distribute does this for you). ## Rate limits, errors, and what to do about them | Code | Meaning | Right move | |---|---|---| | `RATE_LIMITED` (429) | Per-tenant API rate limit. Headers: `Retry-After`, `X-RateLimit-Bucket`. | Pause the tool loop for `retryAfterSec`. Tighter on `bucket: "llm"` (60/min) — searches and signals/run are LLM-heavy. | | `LINKEDIN_RATE_LIMITED` | LinkedIn returned 429/999/checkpoint to one of the customer's sessions. That session is paused for 24 h. | If pinned, switch to auto-distribute (`sessionId: null`) so other sessions absorb the load. If already auto, prompt the user to connect another account. Other sessions keep working. | | `NO_SESSION_AVAILABLE` | Every connected session is rate-limited or out of daily budget. | The user needs to connect another LinkedIn account, or wait for sessions to cool down. | | `SESSION_NOT_FOUND` | Pinned session id is invalid or revoked. | Fall back to `sessionId: null` (auto). | | `TOO_MANY_SESSIONS` (403) | Tenant hit the per-tenant LinkedIn-session cap (default 10). | Disconnect an unused session before connecting a new one. | | `WEBHOOK_LIMIT_REACHED` (403) | Tenant hit the 100-webhook ceiling. | Delete a stale webhook. | | `AUTH_LOCKOUT` (429) | This IP sent 20 failed-auth attempts in 60 s and is locked out for 15 min. | Stop retrying with the bad key. Surface a clear "fix MYAGENTMAIL_KEY" error to the user. | The SDK populates `err.retryAfterSec` and `err.bucket` on `MyAgentMailError` for 429s — honor them and you'll never trip the auth lockout from a rate-limit retry loop. ## What this surface deliberately does not do - **No scraping middlemen.** The customer's real LinkedIn session is used. Apify / proxy farms / "rotating residential" are not involved. - **No DMs / message sending.** Only connection requests today. Use email for follow-up after the connection is accepted. - **No firmographic enrichment beyond LinkedIn role + company.** Wire Apollo / Clearbit / your own if you need industry, headcount, HQ. - **No multi-step email sequences.** Send each follow-up via the messages API; orchestrate cadence in your code or via the starter repo's `outboundExecuteCycle` pattern. - **No hard guarantee LinkedIn data is "fresh."** Profile lookups are cached tenant-wide for 7 days. A user who switched jobs three days ago may still surface with their old role until the cache expires. ## See also - [References → webhooks.md](./webhooks.md) — generic webhook delivery (separate from signal webhooks; same HMAC pattern). - [References → websockets.md](./websockets.md) — real-time inbox events (separate channel from signals). - KB → "Build an AI lead agent in 6 API calls" at . - Open-source starter implementing this whole loop: . --- ## references/webhooks.md # Webhooks — HTTP delivery of inbox events Register an HTTPS endpoint once, receive a POST every time an event fires on any inbox in your tenant (or in your workspace, if using a `wk_` key). Use webhooks when you want **durable, retried** delivery to a long-running service — CRM sync, audit logging, support ticket creation. If you don't need persistence and the agent runtime is short-lived, open a WebSocket instead (see `websockets.md`). ## Event types | event_type | When it fires | |---|---| | `message.received` | A new inbound email is stored in an inbox | | `message.sent` | An outbound send succeeds (via REST or draft send) | | `message.bounced` | A bounce DSN is detected for one of your sends | ## Creating a webhook ```typescript const webhook = await mam("POST", "/webhooks", { url: "https://yourapp.com/webhooks/mam", events: ["message.received", "message.bounced"], }); console.log(webhook.secret); // HMAC secret — ONLY shown at creation time ``` ```python webhook = mam("POST", "/webhooks", { "url": "https://yourapp.com/webhooks/mam", "events": ["message.received", "message.bounced"], }) print(webhook["secret"]) # store immediately ``` The response includes a `secret` that's only returned once. Store it in your config — you need it to verify signatures on incoming payloads. ## Managing webhooks ```typescript // List const { webhooks } = await mam("GET", "/webhooks"); // Get one const one = await mam("GET", `/webhooks/${webhook.id}`); // Update — change URL, event set, or disable await mam("PATCH", `/webhooks/${webhook.id}`, { events: ["message.received"], isActive: false, }); // Delete await mam("DELETE", `/webhooks/${webhook.id}`); ``` ## Event payload shape Every event is a `POST` to your registered URL with a JSON body: ```json { "event": "message.received", "messageId": "2a7b...", "inboxId": "5157f9c5-...", "threadId": "6173425c-...", "from": "Jane Doe ", "to": "scout-abc@myagentmail.com", "subject": "Re: quick question", "hasAttachments": true, "timestamp": "2026-04-11T15:30:52.540Z" } ``` The payload intentionally does **not** include the full body — fetch it with `GET /v1/inboxes/:id/messages/:messageId` when you handle the event. This keeps the webhook POST small and lets your handler decide when to pay the cost of loading a large HTML body or attachment list. ## Signature verification Every webhook POST carries an HMAC-SHA256 signature in a header so you can confirm the request actually came from myagentmail and wasn't replayed. The signature is computed over the raw request body using the webhook's `secret`. ```typescript import crypto from "node:crypto"; function verify(rawBody: string, signatureHeader: string, secret: string): boolean { const expected = crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex"); // Constant-time compare return crypto.timingSafeEqual( Buffer.from(signatureHeader), Buffer.from(expected) ); } // Express handler app.post("/webhooks/mam", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("x-mam-signature") || ""; if (!verify(req.body.toString(), sig, process.env.MAM_WEBHOOK_SECRET!)) { return res.status(401).end(); } const event = JSON.parse(req.body.toString()); // ... handle event res.status(200).end(); }); ``` ```python import hmac, hashlib def verify(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, expected) # Flask handler @app.post("/webhooks/mam") def webhook(): sig = request.headers.get("x-mam-signature", "") if not verify(request.data, sig, os.environ["MAM_WEBHOOK_SECRET"]): return "", 401 event = request.get_json() # ... handle event return "", 200 ``` Always verify before trusting any fields in the payload. An attacker who discovers your webhook URL can post arbitrary JSON to it otherwise. ## Retry behaviour If your endpoint returns a non-2xx response or times out (10 seconds), the delivery is retried with exponential backoff. Consecutive failures lead to increasing delays between retries. Events that can't be delivered after the retry budget is exhausted are logged but dropped. Your handler should be **idempotent** — if you store events to a database, key them on `messageId` so a retry that succeeds after a duplicate delivery doesn't double-insert. ## Choosing webhooks vs WebSockets Pick webhooks when: - Your downstream handler runs on its own schedule (e.g. a nightly job) - You need events persisted even if your agent process is offline - You're fanning out to a third-party service (Slack, Linear, CRM) - You want a permanent audit log of every event Pick WebSockets when: - An agent turn is actively waiting on a specific reply - You can't expose a public URL (ephemeral container, Lambda, laptop) - Sub-second latency matters and you're okay missing events if the socket isn't connected - You want to subscribe to a narrow filter (specific inbox, specific event type) without registering a webhook per filter You can use both together — register a webhook for durable storage, open a WebSocket for the current agent turn. --- ## references/websockets.md # WebSockets — real-time events without a public URL Open a persistent connection to myagentmail and receive events as JSON frames the moment they fire. Use this when the agent is **actively waiting** for mail and you don't want to run a webhook receiver — the canonical example is the **inline 2FA code flow**: an agent signs up for a third-party service, waits for the verification email, extracts the code, and submits it — all in one turn without any public URL. The wire protocol is compatible with agentmail.to's WebSocket API, so agents written against either SDK can use the same frame shapes. ## Endpoint ``` wss://myagentmail.com/v1/ws ``` Auth via the `X-API-Key` header (Node, Bun) or the `?api_key=` query parameter (browser `new WebSocket`, where you can't set headers). There's also a legacy per-inbox alias: `wss://myagentmail.com/v1/inboxes/:id/ws` — it auto-subscribes to the one inbox in the URL, so you can skip the subscribe frame entirely. Use it for quick testing. ## Basic flow ```typescript import WebSocket from "ws"; const ws = new WebSocket("wss://myagentmail.com/v1/ws", { headers: { "X-API-Key": process.env.MYAGENTMAIL_KEY! }, }); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe", event_types: ["message.received"], inbox_ids: [process.env.INBOX_ID!], })); }); ws.on("message", (raw) => { const frame = JSON.parse(raw.toString()); switch (frame.type) { case "connected": console.log("connected as", frame.email); break; case "subscribed": console.log("subscribed to", frame.inbox_ids); break; case "ping": /* keepalive, optional: send a pong */ break; case "event": if (frame.event_type === "message.received") { console.log("new mail from", frame.message.from); console.log("subject:", frame.message.subject); console.log("body:", frame.message.plain_body); } break; case "error": console.error("server error:", frame.message); break; } }); ``` ```python import asyncio, json, os, websockets async def listen(): key = os.environ["MYAGENTMAIL_KEY"] async with websockets.connect( "wss://myagentmail.com/v1/ws", additional_headers={"X-API-Key": key}, ) as ws: await ws.send(json.dumps({ "type": "subscribe", "event_types": ["message.received"], "inbox_ids": [os.environ["INBOX_ID"]], })) async for raw in ws: frame = json.loads(raw) if frame["type"] == "event" and frame["event_type"] == "message.received": print("new mail from", frame["message"]["from"]) print("subject:", frame["message"]["subject"]) asyncio.run(listen()) ``` ## Frame shapes ### Client → server ```jsonc // Subscribe to events. Filters are optional; omit any to match everything. { "type": "subscribe", "event_types": ["message.received", "message.sent", "message.bounced"], // max 10 "inbox_ids": ["5157f9c5-..."], // max 10 "workspace_ids": ["2ceac37e-..."] // max 10 } // Optional keepalive pong (server ignores it, but it's there if your // library is strict about replying to ping frames). { "type": "pong" } ``` The `workspace_ids` field also accepts `pod_ids` as a spec-compatible alias (agentmail.to calls workspaces "pods"). Workspace-scoped API keys (`wk_...`) are automatically locked to their own workspace — you can't subscribe to a workspace outside your key's scope. ### Server → client ```jsonc // Immediately after successful handshake { "type": "connected", "inboxId": "...", "email": "you@myagentmail.com" } // Response to your subscribe frame { "type": "subscribed", "event_types": ["message.received"], "inbox_ids": ["5157f9c5-..."], "workspace_ids": [] } // Every time a matching event fires { "type": "event", "event_type": "message.received", "event_id": "evt_01HXYZ...", // unique per event, use for dedup "message": { "inbox_id": "5157f9c5-...", "thread_id": "6173425c-...", "message_id": "1c5d139e-...", "from": "noreply@github.com", "to": ["you@myagentmail.com"], "subject": "Your one-time code is 483921", "plain_body": "Your code: 483921", "html_body": "

Your code: 483921

", "timestamp": "2026-04-11T09:12:34.000Z" }, "thread": { "thread_id": "6173425c-...", "subject": "Your one-time code is 483921" } } // Keepalive every 30s. Proxies between you and us will drop idle // sockets around 60–120s; these pings keep the connection warm. { "type": "ping" } // Error frame — sent when a subscribe filter is invalid, etc. { "type": "error", "message": "Forbidden inbox_id: ..." } ``` ## The 2FA pattern The flagship use case: an agent signs up for a third-party service, waits for the verification email, extracts the code, submits it, all in one turn. No public URL required. ```typescript import WebSocket from "ws"; import { randomUUID } from "node:crypto"; async function getVerificationCode(siteUrl: string, timeoutMs = 120_000) { const KEY = process.env.MYAGENTMAIL_KEY!; const API = "https://myagentmail.com/v1"; // 1. Create a fresh inbox just for this signup const inbox = await fetch(`${API}/inboxes`, { method: "POST", headers: { "X-API-Key": KEY, "Content-Type": "application/json" }, body: JSON.stringify({ username: `signup-${randomUUID().slice(0, 8)}` }), }).then((r) => r.json()); // 2. Open a WebSocket subscribed to that inbox const ws = new WebSocket("wss://myagentmail.com/v1/ws", { headers: { "X-API-Key": KEY }, }); const codePromise = new Promise((resolve, reject) => { const timer = setTimeout(() => { ws.close(); reject(new Error("Timed out waiting for 2FA code")); }, timeoutMs); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe", event_types: ["message.received"], inbox_ids: [inbox.id], })); }); ws.on("message", (raw) => { const frame = JSON.parse(raw.toString()); if (frame.type !== "event") return; if (frame.event_type !== "message.received") return; // Match a 4–8 digit numeric code anywhere in the body or subject const text = (frame.message.subject || "") + " " + (frame.message.plain_body || ""); const code = text.match(/\b\d{4,8}\b/)?.[0]; if (code) { clearTimeout(timer); ws.close(); resolve(code); } }); ws.on("error", (err) => { clearTimeout(timer); reject(err); }); }); // 3. Fill the signup form with inbox.email (your browser automation) await fillSignupForm(siteUrl, inbox.email); // 4. Wait for the verification email to arrive const code = await codePromise; // 5. Submit the code and continue the signup await submitVerificationCode(siteUrl, code); return inbox; // hold onto the inbox if you'll need future mail for this account } ``` ## Scoping and filters | Key type | What it can subscribe to | |---|---| | `tk_` tenant master | Every inbox in the tenant; can filter by `workspace_ids` and/or `inbox_ids` | | `wk_` workspace master | Every inbox in its workspace; `workspace_ids` is pre-locked | | `ak_` inbox scoped | Only its own inbox | Filters combine with AND semantics — subscribing with `{ event_types: ["message.received"], inbox_ids: ["a", "b"] }` means "inbound events on either inbox a or b". Max 10 items in each list. ## Reconnection Sockets aren't automatically retried. If the connection drops mid-turn, you'll miss events that happen between the drop and your reconnect. If that matters for your flow: - **For inline agent turns** — just reconnect; the events you're waiting for will arrive after you re-subscribe. If it's been longer than your timeout, poll `GET /v1/inboxes/:id/messages?direction=inbound` to backfill anything missed. - **For durable consumption** — use HTTP webhooks instead. They retry on your behalf. The two delivery paths are complementary: use WebSocket for the in-line agent turn and webhooks for durable storage in the same deployment. ## Keepalive The server emits `{ "type": "ping" }` every 30 seconds. You don't have to respond to it — it's just traffic to keep proxies and load balancers from closing an idle socket. Most browser and Node WebSocket libraries handle the underlying protocol-level ping frames automatically; the JSON `ping` we emit is a separate application-level heartbeat that's safe to ignore.