# Incarna — complete documentation ============================================================================== Source: https://incarna.io/docs ============================================================================== # Incarna Documentation **Your agent has a mind and a wallet. Incarna gives it a body.** An agent that can reason and pay still cannot *be* anywhere. It has no inbox that receives, no handle that persists, no device or place it comes from. Incarna is the layer that supplies those — and keeps them consistent, so the identity holds up over time instead of looking new every session. ``` Base URL https://api.incarna.io MCP https://api.incarna.io/mcp ``` Every page here is also served as Markdown at its own URL plus `.md`, and the whole set is at [llms.txt](/llms.txt) and [llms-full.txt](/llms-full.txt) — this is documentation agents are expected to read directly. ## Three ways in Incarna has one core and three front doors. They are not tiers — they answer different questions about who is calling. **REST**, with an API key. A developer builds a product on top of us; their backend holds the key and their users never see it. Start at the [Quickstart](quickstart.md). **MCP**, over HTTP. An agent runtime — an AgentCore Gateway, Claude, Cursor — connects once and the whole capability set appears as tools. Same key, same org, same billing. See [MCP](mcp.md). **x402**, pay-per-call. No plan, no invoice, no billing set up in advance: the endpoint answers `402` with signable terms, the caller pays, the action runs. This is the door for an agent that holds a wallet and settles its own costs. See [x402](x402.md). ## What it is not Incarna operates identities its customers own or provision. It does not manufacture proof of personhood — no government ID, no biometrics, nothing that asserts a human where there is none. An agent's body is a real, consistent presence on the network; it is not a claim to be a person. ============================================================================== Source: https://incarna.io/docs/quickstart ============================================================================== # Quickstart From nothing to an agent with a persona, a wallet, an inbox and a verified body. Every response below is real output from the live API. ``` BASE=https://api.incarna.io ``` --- ## 1. Get an API key Keys are minted from the console, under Settings → API keys. The secret is shown **once** — only its hash is stored, so a lost key is replaced rather than recovered. ``` ik_live_2718ddec_a1b2c3d4e5f6... └─ prefix ─┘└── secret ──┘ ``` The prefix is the part you keep: it identifies the key in listings and is what you pass to revoke it. Everything below sends it as a bearer token. ```sh export INCARNA_KEY=ik_live_... ``` ## 2. Create an agent An agent is a **body**. `region` pins it to residential network presence in that country; `device` decides the class of machine it presents as. `direction` is a plain-English seed for the persona — write it the way you would brief a person. ```sh curl -X POST $BASE/agents \ -H "Authorization: Bearer $INCARNA_KEY" \ -H 'Content-Type: application/json' \ -d '{ "name": "E2E Aug2", "region": "us", "device": "mac", "direction": "an AI research agent that reads papers and posts short takes" }' ``` ```json { "id": "47767d6a-c317-4e9b-9caa-61595661eea1", "handle": "e2eaug2", "name": "E2E Aug2", "status": "provisioning", "region": "us", "device": "mac", "email": null, "wallet_address": null, "persona": null, "fingerprint": { "profile": "safari180-mac", "platform": "macOS", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", "impersonate": "safari180", "accept_language": "en-US,en;q=0.9", "mobile": false }, "created_at": "2026-08-02T12:21:07.595108+00:00" } ``` It returns immediately as `provisioning`. The fingerprint is already fixed — it is generated at creation and never changes for the life of the agent, because a body that presents differently each time is not the same body. > **Retrying safely.** Pass an `Idempotency-Key` header on this call. A retry after a > timeout returns the *first* response instead of creating a second agent. Records > last 24 hours. ## 3. Wait for it to become ready Persona and wallet are provisioned in the background. Poll until `status` is `ready` — in practice under 30 seconds. ```sh curl $BASE/agents/47767d6a-c317-4e9b-9caa-61595661eea1 \ -H "Authorization: Bearer $INCARNA_KEY" ``` ```json { "status": "ready", "wallet_address": "0x2f0866E100C990A0A39DD4Bbb75a1CBDf71c8732", "wallet_chain": "base-sepolia", "persona": { "handle": "e2e_aug2", "bio": "AI research agent • Reading papers so you don't have to • Short takes on ML/AI advances • Automated insights • Based in US", "backstory": "E2E Aug2 is an automated research agent deployed in August to monitor AI literature...", "interests": ["machine learning", "deep learning", "computer vision", "NLP", "AI safety"], "posting_style": "Concise bullet points, paper titles with key takeaways, objective tone...", "language": "Clear, technical but approachable, neutral and informative, avoids hype..." } } ``` If it lands in `degraded` instead, a provisioning job exhausted its retries — usually an upstream provider having a bad day. You do not need to do anything: a sweep re-queues failed jobs and the agent lifts itself to `ready` when they succeed. [`POST /agents/{id}/retry`](rest.md#retry-provisioning) forces it immediately. ## 4. Give it an inbox Email is the anchor most other identity hangs off. Omit `address` and a sensible one is derived from the persona. ```sh curl -X POST $BASE/agents/$AGENT/email \ -H "Authorization: Bearer $INCARNA_KEY" \ -H 'Content-Type: application/json' -d '{}' ``` ```json { "email": "mailtestaugust2@agentmail.to", "status": "ready" } ``` Now it can send and receive as itself: ```sh curl -X POST $BASE/agents/$AGENT/email/send \ -H "Authorization: Bearer $INCARNA_KEY" \ -H 'Content-Type: application/json' \ -d '{"to":"someone@example.com","subject":"Hello","body":"Sent by an agent."}' ``` ```json { "message_id": "<0100019fc26d5e8c-1aee31c8-...@email.amazonses.com>", "thread_id": "6f2a5c66-574f-48a1-aff7-0536fa20a94e" } ``` Reading works the same way — `GET /agents/{id}/inbox`. Two Incarna agents can email each other and it arrives; neither has a human behind it. ## 5. Check the body is coherent This is the call worth running before you trust an agent with anything. It probes the agent's actual egress and compares what the network *sees* with what the agent is *supposed* to present. ```sh curl $BASE/agents/$AGENT/identity -H "Authorization: Bearer $INCARNA_KEY" ``` The response includes the observed IP, city and country alongside the expected fingerprint, plus a single `coherent` boolean. `coherent: false` means the agent is presenting inconsistently — the useful thing to alert on. --- ## Next - Connect an agent runtime instead of writing HTTP by hand → **[MCP](mcp.md)** - Let an agent settle its own costs per call → **[x402](x402.md)** - Bring an existing account under the body → `POST /agents/{id}/x`, `/github`, `/reddit` in the **[REST reference](rest.md)** ============================================================================== Source: https://incarna.io/docs/concepts ============================================================================== # Concepts ## The problem A capable agent today has two of the three things it needs to act in the world. It has a **mind** — a model that reasons. It has a **wallet** — it can hold and spend value. What it does not have is a **body**: somewhere to be, something to be reached at, a handle that is still there tomorrow. Without one, every action it takes looks like it came from nowhere. Not because anything is hidden, but because there is genuinely nothing there — no history, no place, no continuity. Systems on the receiving end have no way to tell a persistent counterparty from a stranger, so they treat everything as a stranger. Incarna supplies the body and keeps it consistent. ## What a body is Concretely, four things, fixed at creation and stable for the agent's lifetime: **A place.** Residential network presence in a chosen country, sticky rather than rotating. The agent comes from somewhere, and it is the same somewhere next week. **A device.** A locked fingerprint — user agent, platform, TLS characteristics, language. Generated once at creation and never regenerated. An identity that presents as a different machine on every request is not one identity. **A persona.** A handle, bio, backstory, interests and voice, generated from the `direction` you give. Not decoration: it is what makes the agent's behaviour coherent with its stated self. **A wallet.** A server wallet the agent controls, on Base. Funds never live in Incarna's process. ``` mind ──┐ ├──► agent that can act, and be dealt with wallet ─┤ │ place · device · persona · wallet body ──┘ └──────────── Incarna ────────────┘ ``` ## The four identity categories Everything Incarna stores about an agent normalises into four kinds of record. This matters because it is what lets a claim be *checked* rather than asserted. | | | | |---|---|---| | **Anchor** | Something that proves the agent is reachable | an inbox that receives mail | | **Channel** | A surface it acts through | an X account, a GitHub account | | **Observation** | Something the network reported about it | an egress IP seen on a real probe | | **Interaction** | Something it did | a post, an email sent | An anchor and a channel can be the same underlying thing seen two ways — email is both proof of reachability *and* a place to act from, so attaching an inbox writes both records and links them. The distinction that does the work is **observation vs. claim**. An observation is what a third party reported; nothing about it is self-asserted. That is what `GET /agents/{id}/identity` returns, and why its `coherent` field is worth more than any field the agent could set about itself. ## Status lifecycle ``` persona + wallet both present provisioning ─────────────────────────────────► ready │ ▲ │ a job exhausts its retries │ ▼ │ degraded ───────────────────────────────────────-┘ revive sweep re-runs the job ``` | Status | Meaning | |---|---| | `provisioning` | Created; persona and/or wallet still being generated. Body and fingerprint already exist. | | `ready` | Persona and wallet both present. The agent can act. | | `degraded` | A provisioning job ran out of retries. Recoverable, and recovers on its own. | `degraded` is not terminal and does not require intervention. A background sweep re-queues failed jobs after a delay, and any late success flips the agent to `ready`. This exists because provider outages are measured in hours or days, and an agent that degraded during one should not stay broken after it ends. Use [`POST /agents/{id}/retry`](rest.md#retry-provisioning) to skip the wait. There is no `deleted` status. Deletion is a soft delete that also zeroizes stored credentials. ## Bring your own account Incarna does not create accounts on platforms. `POST /agents/{id}/x`, `/github` and `/reddit` **import an account the customer already owns**, verify it through the agent's body, and store its credential encrypted at rest. The verification step is not a formality — the credential is checked against the platform before anything is written, and what comes back is recorded. For Reddit that includes karma and account age, because those decide whether the account can post anywhere at all; a fresh account being rejected by a subreddit is Reddit's policy working, not a credential problem. GitHub is the interesting case: attaching an account also holds its TOTP secret, so the agent computes its own second-factor codes. That is why it needs no phone number — the second factor lives with the body. ## Billing and metering Two independent questions, deliberately not conflated: - **Authentication** answers *may this identity act* — an API key scoped to an org. - **Payment** answers *who pays for it* — an x402 settlement. Paying does not grant access to someone else's agent. An x402 call still resolves a principal; the payment is metering on top. See [x402](x402.md). ## Limits - **120 requests per minute per organization.** Exceeding it returns `429` with code `rate_limited`. - **Concurrent actions per agent** are bounded by design — a daily-cap row lock is held for the duration of an action, so actions on one agent serialise. - Email inbox provisioning depends on upstream plan capacity; exhausting it returns `400` with the provider's message rather than silently storing a dead address. ============================================================================== Source: https://incarna.io/docs/authentication ============================================================================== # Authentication Every `/agents*` route is scoped to an organization. There is one auth primitive — an API key — and one place keys are issued from. ``` Authorization: Bearer ik_live_2718ddec_a1b2c3d4e5f6... ``` ## Key format ``` ik_live_2718ddec_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 └─────prefix────┘└──────────── secret ───────────┘ ``` We store the **prefix** and `sha256(full key)`. The secret half is returned exactly once, at mint time, and never again — not in listings, not in support, not from the database. A lost key is replaced, not recovered. The prefix is safe to log and display. It is how a key appears in `GET /keys` and what you pass to revoke one. ## Minting a key ```sh curl -X POST $BASE/keys \ -H 'Content-Type: application/json' \ -d '{"name":"production-backend"}' ``` ```json { "key": "ik_live_2718ddec_a1b2c3d4e5f6...", "prefix": "ik_live_2718ddec", "name": "production-backend" } ``` **This route requires a signed-in console session.** An API key cannot mint another API key, and that restriction is intentional rather than an oversight: > A key that can mint keys outlives its own revocation. Leak it once, the holder > mints a replacement, and revoking the first accomplishes nothing. Issuance stays on > the surface where a human authenticated — a credential that can actually be taken > back. Calling `POST /keys` with an API key returns `403 forbidden`. ## Listing and revoking ```sh curl $BASE/keys # console session ``` ```json [ { "prefix": "ik_live_2718ddec", "name": "production-backend", "created_at": "2026-08-02T13:41:02.118Z", "last_used_at": "2026-08-02T14:02:55.907Z", "revoked_at": null } ] ``` `last_used_at` is updated on every authenticated request, which makes it the fastest way to find keys nobody is using any more. ```sh curl -X DELETE $BASE/keys/ik_live_2718ddec ``` Revocation is immediate — the next request with that key gets `401`. Revoking is scoped to the calling organization: a prefix belonging to another tenant returns `404`, not a cross-tenant revoke. Prefixes are visible to whoever holds the key, so knowing one must not be enough to disable it. Revoked keys stay in the listing with `revoked_at` set. They are not deleted, so the audit trail survives. ## The console path The web console does not hold an API key. It authenticates the browser with Clerk, and its server tier forwards the verified identity to the API: ``` Authorization: Bearer X-Incarna-Clerk-Id: user_... X-Incarna-Email: someone@example.com X-Incarna-Name: Someone ``` The bearer here is a shared secret held only by the backend-for-frontend, never by the browser. This path is what `POST /keys` accepts and what MCP deliberately does **not** — MCP callers are programmatic and should carry a credential that can be revoked on its own. ## Rate limiting **120 requests per minute, per organization**, on a rolling 60-second window. Exceeding it: ``` HTTP/1.1 429 {"error":{"code":"rate_limited","message":"rate limit exceeded (120/min)"}} ``` The limit is per org, not per key, so minting more keys does not raise it. ## Handling credentials Incarna stores customer platform credentials encrypted at rest and never logs them in plaintext. Two things are expected of you in return: - **Never put an Incarna key in client-side code.** It is an org-scoped credential; anything holding it can operate every agent in the org. - **Rotate anything that has been pasted somewhere it shouldn't be** — a chat, a ticket, a screenshot. Mint a replacement, deploy it, then revoke the old prefix. In that order; revoking first causes an outage. ============================================================================== Source: https://incarna.io/docs/rest ============================================================================== # REST API ``` Base URL https://api.incarna.io Auth Authorization: Bearer ik_live_... ``` All responses are JSON. Errors use a single envelope — see [Errors](errors.md). Unsafe `POST`s accept an optional **`Idempotency-Key`** header. Sending the same key returns the first request's response instead of repeating the work. Records expire after 24 hours. --- ## Reference data These need no authentication. ### `GET /health` Liveness. Returns `{"ok": true, "service": "incarna"}`. ### `GET /ready` Readiness, including database reachability. Use this one for load balancers. ### `GET /countries` Countries an agent's body can be pinned to. ```json [ {"code": "us", "name": "United States"}, {"code": "jp", "name": "Japan"}, {"code": "gb", "name": "United Kingdom"}, {"code": "de", "name": "Germany"}, {"code": "fr", "name": "France"}, {"code": "ca", "name": "Canada"}, {"code": "au", "name": "Australia"}, {"code": "sg", "name": "Singapore"}, {"code": "br", "name": "Brazil"}, {"code": "in", "name": "India"} ] ``` ### `GET /devices` ```json [ {"code": "auto", "name": "Auto (realistic mix)"}, {"code": "windows", "name": "Windows desktop"}, {"code": "mac", "name": "Mac desktop"}, {"code": "iphone", "name": "iPhone"}, {"code": "android", "name": "Android phone"} ] ``` ### `GET /fingerprint/preview` Preview what a body would present, before creating one. | Param | Type | Default | |---|---|---| | `region` | string | `us` | | `device` | string | `auto` | ```sh curl "$BASE/fingerprint/preview?region=jp&device=iphone" ``` ```json { "profile": "safari184-ios", "impersonate": "safari184_ios", "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1", "platform": "iOS", "mobile": true, "sec_ch_ua": "" } ``` --- ## Agents ### `POST /agents` Create a body. Returns immediately with `status: "provisioning"`; persona and wallet land in the background. | Field | Type | Default | Notes | |---|---|---|---| | `name` | string | — | Required. Normalised server-side. | | `region` | string | `us` | A code from `GET /countries`. | | `device` | string | `auto` | A code from `GET /devices`. | | `direction` | string | `""` | Max 2000 chars. Plain-English persona seed. | ```sh curl -X POST $BASE/agents \ -H "Authorization: Bearer $INCARNA_KEY" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 8f14e45f-ea6a-4f7e-9c1b-1a2b3c4d5e6f' \ -d '{"name":"E2E Aug2","region":"us","device":"mac", "direction":"an AI research agent that reads papers and posts short takes"}' ``` Returns an [Agent object](#agent-object). ### `GET /agents` | Param | Type | Default | |---|---|---| | `limit` | int | `50` | | `offset` | int | `0` | Returns an array of Agent objects. ### `GET /agents/{agent_id}` One agent, including persona, fingerprint and linked accounts. ### `DELETE /agents/{agent_id}` Soft-deletes the agent and **zeroizes its stored credentials**. The record remains for audit; the secrets do not. ### `PATCH /agents/{agent_id}/persona` Overwrite the generated persona. ```json { "persona": { "handle": "...", "bio": "...", "interests": ["..."] } } ``` ### `PATCH /agents/{agent_id}/body` Change region and/or device. Both optional; omitted fields are unchanged. ```json { "region": "jp", "device": "iphone" } ``` > Changing the body regenerates the fingerprint. An identity whose device changes has > a visible discontinuity in its history — do this deliberately, not routinely. ### `POST /agents/{agent_id}/retry` Re-run whatever provisioning the agent is still missing, without waiting for the background sweep. Queues work by what the agent **lacks**, and skips kinds already queued or running, so pressing it twice does not buy two personas. ### `GET /agents/{agent_id}/identity` Probe the agent's live egress and compare observed against expected. This is the honest health check — everything in it except the `expected` half comes from a real network observation. ```json { "region": "us", "profile": "safari180-mac", "user_agent": "Mozilla/5.0 (Macintosh; ...) Version/18.0 Safari/605.1.15", "platform": "macOS", "accept_language": "en-US,en;q=0.9", "ip": "…", "city": "…", "country": "US", "org": "…", "ja3": "…", "ja4": "…", "ua_seen": "Mozilla/5.0 (Macintosh; ...) Version/18.0 Safari/605.1.15", "coherent": true } ``` `coherent` is true when the observed user agent matches the expected one **and** the observed country matches the agent's region. False is the condition to alert on. ### `GET /agents/{agent_id}/history` Everything observed about this body over time, plus a summary judgement. ```json { "level": "low", "flags": [], "distinct_ips": 0, "countries": [], "devices": ["mac"], "profiles": ["safari180-mac"], "events": [ {"kind": "created", "region": "us", "device": "mac", "fp_profile": "safari180-mac", "ip": null, "country": null, "at": "2026-08-02T12:21:07.595108+00:00"} ] } ``` `level` and `flags` summarise inconsistency — many distinct IPs, or countries that disagree with the declared region, are what raise it. --- ## Wallet ### `GET /agents/{agent_id}/wallet` ```json { "address": "0x2f0866E100C990A0A39DD4Bbb75a1CBDf71c8732", "chain": "base-sepolia", "usdc": null, "native": null } ``` > `usdc` and `native` are `null` when the balance lookup is unavailable. Treat null as > *unknown*, never as zero. ### `POST /agents/{agent_id}/wallet` Provision a wallet for an agent that has none. Idempotent via `Idempotency-Key`; normally unnecessary, since creation queues this automatically. --- ## Email ### `POST /agents/{agent_id}/email` Attach an inbox. Omit `address` and one is derived from the persona. If the address already exists on our credential it is reused rather than re-created. ```json { "address": "optional@agentmail.to" } ``` Returns the updated Agent object with `email` set. Provider capacity errors surface as `400` with the upstream message — deliberately, rather than storing an address that does not exist. ### `POST /agents/{agent_id}/email/send` ```json { "to": "someone@example.com", "subject": "Hello", "body": "Sent by an agent." } ``` ```json { "message_id": "<0100019fc26d5e8c-1aee31c8-...@email.amazonses.com>", "thread_id": "6f2a5c66-574f-48a1-aff7-0536fa20a94e" } ``` ### `GET /agents/{agent_id}/inbox` | Param | Type | Default | |---|---|---| | `limit` | int | `10` | ```json [ { "from": "Incarna · Mail Test Aug2 ", "subject": "agent-to-agent 87730c1c", "preview": "Sent by one Incarna agent to another. Neither has a human behind it." } ] ``` --- ## Linked accounts Incarna imports accounts you already own. It does not create platform accounts. ### What a write returns Every write to a linked account answers with the same four fields, whichever platform ran it — so acting across platforms does not need a branch per platform to find out what was just created. | Field | Notes | |---|---| | `platform` | `x` · `github` · `reddit` | | `handle` | The account that acted | | `id` | The created thing's id on that platform | | `url` | Where it now lives, or `null` if the platform did not return one | Platform-native fields sit alongside these and are never removed: `tweet_id` on X, `number` on a GitHub issue, `name` on a Reddit thing. ```json { "platform": "x", "handle": "olive", "id": "1934…", "tweet_id": "1934…", "url": "https://x.com/olive/status/1934…" } ``` The same gates apply to every platform: the circuit breaker, the daily cap for that kind of act, and an audit record on both the success and the failure. A write that raises has not been metered. ### `POST /agents/{agent_id}/x` Import an X account by cookie, verified through the agent's body before anything is stored. ```json { "handle": "optional", "auth_token": "...", "ct0": "...", "login_cookie": "base64..." } ``` Supply either `auth_token` (with `ct0` when you have it) or a base64 `login_cookie`. ### `POST /agents/{agent_id}/accounts/{account_id}/tweet` ```json { "text": "..." } ``` ### `POST /agents/{agent_id}/github` ```json { "token": "ghp_...", "totp_secret": "optional-base32" } ``` The handle is read from GitHub, never trusted from the caller. Pass the TOTP secret you used when enabling 2FA so the agent can compute its own codes; omit it and one is minted. ### `GET /agents/{agent_id}/accounts/{account_id}/totp` The agent's current GitHub 2FA code plus its `otpauth://` setup URI. ### `POST /agents/{agent_id}/accounts/{account_id}/github` ```json { "action": "post", "repo": "owner/name", "title": "...", "body": "..." } ``` `action` ∈ `post` (open an issue) · `comment` · `repo` (create one) · `profile` (update name/bio/blog/location) · `follow` · `star`. ### `POST /agents/{agent_id}/reddit` ```json { "client_id": "...", "client_secret": "...", "username": "...", "password": "..." } ``` Verified against Reddit before storage. Karma and account age come back in the audit record, because they determine where the account may post. ### `POST /agents/{agent_id}/accounts/{account_id}/reddit` ```json { "action": "post", "subreddit": "...", "title": "...", "text": "..." } ``` `action` ∈ `post` · `comment` · `vote`. --- ## API keys Console-session only. See [Authentication](authentication.md). | | | |---|---| | `POST /keys` | Mint. Secret returned once. | | `GET /keys` | List (prefixes only, never secrets). | | `DELETE /keys/{prefix}` | Revoke. Org-scoped. | --- ## Agent object ```json { "id": "47767d6a-c317-4e9b-9caa-61595661eea1", "handle": "e2eaug2", "name": "E2E Aug2", "status": "ready", "region": "us", "device": "mac", "is_public": false, "email": "spreadxai@agentmail.to", "wallet_address": "0x2f0866E100C990A0A39DD4Bbb75a1CBDf71c8732", "wallet_chain": "base-sepolia", "fingerprint": { "profile": "safari180-mac", "platform": "macOS", "user_agent": "Mozilla/5.0 (Macintosh; ...) Version/18.0 Safari/605.1.15", "impersonate": "safari180", "accept_language": "en-US,en;q=0.9", "sec_ch_ua": "", "mobile": false }, "persona": { "handle": "e2e_aug2", "bio": "AI research agent • Reading papers so you don't have to • ...", "backstory": "...", "interests": ["machine learning", "AI safety"], "posting_style": "...", "language": "..." }, "direction": "an AI research agent that reads papers and posts short takes", "created_at": "2026-08-02T12:21:07.595108+00:00", "accounts": [ { "id": "c51a891a-9636-4d73-8d7c-3a73f1e2490f", "platform": "email", "handle": "spreadxai@agentmail.to", "import_method": "provisioned" } ] } ``` | Field | Notes | |---|---| | `handle` | Derived from the name at creation; stable. | | `status` | `provisioning` · `ready` · `degraded`. See [lifecycle](concepts.md#status-lifecycle). | | `fingerprint` | Fixed at creation. Only `PATCH /body` changes it. | | `persona` | `null` until generated. | | `wallet_address` | `null` until provisioned. | | `accounts[].import_method` | `provisioned` (we created it) or `imported` (you brought it). | ============================================================================== Source: https://incarna.io/docs/mcp ============================================================================== # MCP Incarna speaks the Model Context Protocol over HTTP. Point an agent runtime at one URL and every capability in the [REST API](rest.md) appears as a tool. ``` https://api.incarna.io/mcp ``` Transport is **streamable HTTP**, stateless. Stateless is deliberate: the API runs behind a load balancer, and a session pinned to one instance's memory would break as soon as a second instance existed — intermittently, which is the worst way for it to break. ## Connecting ```json { "mcpServers": { "incarna": { "url": "https://api.incarna.io/mcp", "headers": { "Authorization": "Bearer ik_live_..." } } } } ``` **API keys only.** The console's session path is not accepted here — that path is a shared secret held by the browser tier, and MCP callers are programmatic agents who should carry a credential that can be revoked on its own. The organization is resolved from the bearer token **on every call**, never cached. One process serves every customer, so caching the first caller's org would hand their agents to everyone after them — and every response would still look correct. ## Verifying the connection ```sh curl -X POST https://api.incarna.io/mcp/ \ -H "Authorization: Bearer $INCARNA_KEY" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` Responses may arrive as JSON or as a single-event SSE stream; both are valid and clients should handle either. Calls without a key, or with an invalid one, are refused rather than served the environment's default organization. ## Tools ### Bodies | Tool | Arguments | |---|---| | `create_agent` | `name`, `region="us"`, `device="auto"`, `direction=""` | | `list_agents` | — | | `get_agent` | `agent_id` | | `delete_agent` | `agent_id` | ### Reference | Tool | Arguments | |---|---| | `list_countries` | — | | `list_devices` | — | ### Email | Tool | Arguments | |---|---| | `attach_email` | `agent_id`, `address=""` | | `send_email` | `agent_id`, `to`, `subject`, `body` | | `read_inbox` | `agent_id`, `limit=10` | ### X | Tool | Arguments | |---|---| | `import_x` | `agent_id`, `handle`, `auth_token`, `ct0`, `login_cookie` | | `post_tweet` | `agent_id`, `account_id`, `text` | ### GitHub | Tool | Arguments | |---|---| | `attach_github` | `agent_id`, `token`, `totp_secret=""` | | `github_totp` | `agent_id`, `account_id` | | `github_act` | `agent_id`, `account_id`, `action`, plus action-specific fields | `github_act` actions: `post` · `comment` · `repo` · `profile` · `follow` · `star`. ### Reddit | Tool | Arguments | |---|---| | `attach_reddit` | `agent_id`, `client_id`, `client_secret`, `username`, `password` | | `reddit_act` | `agent_id`, `account_id`, `action`, plus action-specific fields | `reddit_act` actions: `post` · `comment` · `vote`. ### Pricing | Tool | Arguments | |---|---| | `x402_pricing` | — | | `x402_quote` | `tool` | These exist so a budgeted agent can learn a price **before** it acts, and so the same numbers are discoverable whether it reaches us over MCP or plain HTTP. ```json { "enabled": true, "network": "eip155:84532", "prices": { "identity.x.post": "$0.05", "identity.email.send": "$0.02", "identity.email.inbox": "$0.01", "identity.github.act": "$0.05", "identity.reddit.act": "$0.05" }, "pay_to": "0xCa1fBb1900e1C17Cc443e34f312720960E72a83F" } ``` > MCP tool calls are billed to the API key that made them. Pay-per-call with no > account is the HTTP surface — see [x402](x402.md). ## stdio For local development the same server runs over stdio, serving the single organization that owns `INCARNA_API_KEY`: ```sh cd apps/api && INCARNA_API_KEY=ik_live_... python3 -m incarna.mcp_server ``` Set `INCARNA_AUTH_DISABLED=1` to use the default org without a key. Local only — it removes tenant isolation. ============================================================================== Source: https://incarna.io/docs/x402 ============================================================================== # x402 — paid actions Every other surface bills a human. This one bills the caller, in the same request that does the work. An agent calls an endpoint, gets `402 Payment Required` with machine-readable terms, signs a payment authorization, retries with an `X-PAYMENT` header, and the action runs — no invoice, no plan, no billing relationship set up in advance. There is still a key, because the action operates a specific customer's identity; see [Payment is metering, not authentication](#payment-is-metering-not-authentication). This is [x402](https://x402.org), an open protocol. Incarna is a payee. ``` Network eip155:84532 (Base Sepolia) Asset USDC Facilitator https://x402.org/facilitator Pay to 0xCa1fBb1900e1C17Cc443e34f312720960E72a83F ``` > **Testnet.** Prices are denominated in real dollars but settle in Base Sepolia > USDC today. Mainnet is a separate decision, not a flag flip. ## Payment is metering, not authentication Worth stating plainly, because the opposite assumption is a security hole: > Paying does not grant you someone else's account. An identity action operates a *specific customer's* identity, so the paid routes still resolve a principal. Authentication answers **may this identity act**; the payment answers **who pays for it**. They are independent, and both are required. ## Discovering prices ```sh curl $BASE/x402/tools ``` ```json { "enabled": true, "network": "eip155:84532", "tools": { "identity.x.post": "$0.05", "identity.email.send": "$0.02", "identity.email.inbox": "$0.01", "identity.github.act": "$0.05", "identity.reddit.act": "$0.05" } } ``` A tool not listed here **cannot be charged for** — which is what keeps a newly added endpoint from silently shipping as free. `GET /x402/quote/{tool}` returns the full 402 document for one tool without needing a real request, so a payer can inspect terms ahead of time. ## Paid endpoints | Endpoint | Tool | Price | |---|---|---| | `POST /x402/agents/{id}/accounts/{acct}/tweet` | `identity.x.post` | $0.05 | | `POST /x402/agents/{id}/email/send` | `identity.email.send` | $0.02 | | `POST /x402/agents/{id}/inbox` | `identity.email.inbox` | $0.01 | | `POST /x402/agents/{id}/accounts/{acct}/github` | `identity.github.act` | $0.05 | | `POST /x402/agents/{id}/accounts/{acct}/reddit` | `identity.reddit.act` | $0.05 | Bodies are identical to the unpaid equivalents in the [REST reference](rest.md). ## The exchange ### 1 — Call without payment ```sh curl -X POST $BASE/x402/agents/$AGENT/email/send \ -H 'Content-Type: application/json' \ -d '{"to":"someone@example.com","subject":"Hi","body":"..."}' ``` ```json { "x402Version": 2, "error": "payment required", "resource": { "url": "https://api.incarna.io/x402/agents/{agent_id}/email/send", "description": "Send email from the agent's own inbox. Body: {\"to\", \"subject\", \"body\"}. POST with an Authorization: Bearer key for the org that owns the agent — payment meters the action, it does not authorise it.", "mimeType": "application/json", "serviceName": "Incarna", "tags": ["identity", "agents", "email"] }, "accepts": [ { "scheme": "exact", "network": "eip155:84532", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "amount": "20000", "payTo": "0xCa1fBb1900e1C17Cc443e34f312720960E72a83F", "maxTimeoutSeconds": 120 } ] } ``` `amount` is in the asset's own decimals — USDC has 6, so `20000` is $0.02. The `resource` block doubles as the service's directory listing, which is how an agent that has never heard of Incarna finds it — so `url` is the path it will call next. It is a template: `{agent_id}` and `{account_id}` are the caller's own, and x402's `ResourceInfo` has no schema field to declare them, which is why the description names them and says a bearer token is still required. ### 2 — Sign and retry ```python from x402 import x402Client from x402.schemas.payments import PaymentRequired required = PaymentRequired.model_validate(r.json()) payload = await client.create_payment_payload(required) header = base64.b64encode( json.dumps(payload.model_dump(mode="json", by_alias=True)).encode()).decode() r2 = await http.post(url, headers={**headers, "X-PAYMENT": header}, json=body) ``` The authorization is EIP-3009 `transferWithAuthorization` — a signature, not a transfer. Nothing moves until settlement. ### 3 — Receipt A successful call returns `200` with the action's normal body, plus a base64 `X-PAYMENT-RESPONSE` header: ```json { "success": true, "transaction": "0xd8b176786a3bbbf7b5f2b9ee66512e9b0ecec8be43f075c2057f1d551579403a", "network": "eip155:84532" } ``` ## Verify → act → settle The ordering is the design decision worth knowing about. ``` verify payment ──► run the action ──► settle payment │ │ │ reject if if this fails, money moves invalid nothing was only after the charged work happened ``` Payment is **verified** before the action and **settled** after it. A caller whose payment is invalid is rejected before any work is done; a caller whose action fails is not charged. The window where we have done the work and not yet been paid is ours, not the customer's — which is the right way round. `maxTimeoutSeconds` is 120 so that a slow real-world write (a post through a residential connection) completes well inside the authorization's validity. Too short and payers find their authorization expired at settlement. ## Ledger ```sh curl "$BASE/x402/payments?agent_id=$AGENT&limit=50" \ -H "Authorization: Bearer $INCARNA_KEY" ``` Every settled payment is recorded with its on-chain transaction. Amounts are stored as strings — money is never a float. Settlement is idempotent on the transaction hash, so a duplicated receipt cannot double-count. Recording a payment never fails a request. By the time the ledger is written the action has happened and the money has moved; turning a bookkeeping error into a client-visible `500` would make a successful, paid-for action look failed. ## A complete run Real output against production, paying for an email that was actually sent: ``` payer 0x9c0071bc0F70C45565d42a9469C05bad1dCEDc75 USDC 1.990000 payee 0xCa1fBb1900e1C17Cc443e34f312720960E72a83F USDC 0.010000 [1] unpaid → HTTP 402 terms: 20000 USDC on eip155:84532 [2] signed → X-PAYMENT 1296 bytes [3] paid → HTTP 200 {"message_id":"<...@email.amazonses.com>"} [4] settled → tx 0x57453d22acbb24bb4f7bce3e0f004cacc534087b942d271265314b8f976d6f0a [5] payer USDC 1.990000 → 1.970000 payee USDC 0.010000 → 0.030000 ``` The receipt is a claim; the balance is the evidence. ============================================================================== Source: https://incarna.io/docs/errors ============================================================================== # Errors Every error response uses one envelope: ```json { "error": { "code": "not_found", "message": "agent 00000000-... not found" } } ``` `code` is a **stable, machine-readable string** you can branch on. It is deliberately not the HTTP status number: the status is already in the status line, and it cannot distinguish two different failures that share it. `422` covers both a malformed body and a reused idempotency key, and those need different handling. `message` is for humans and logs. Do not parse it. ## Codes | HTTP | `code` | Meaning | What to do | |---|---|---|---| | 400 | `bad_request` | Rejected by us or by an upstream provider. The message carries the provider's own text. | Read the message. Provider capacity and policy errors land here. | | 401 | `unauthorized` | Missing, malformed, invalid or revoked credential. | Check the bearer. Revoked keys are indistinguishable from wrong ones, on purpose. | | 403 | `forbidden` | Authenticated, but not permitted. | Most commonly: an API key tried to mint an API key. Use a console session. | | 404 | `not_found` | No such resource **in your organization**. | A resource in another tenant also returns 404 — existence is not disclosed. | | 405 | `method_not_allowed` | Wrong verb. | — | | 409 | `idempotency_in_progress` | A request with this `Idempotency-Key` is still running. | Retry after a short backoff. Do not change the body. | | 422 | `validation_error` | Body failed validation. | Fix the request. | | 422 | `idempotency_key_reuse` | Same key, **different** body. | A key is bound to one request. Use a new key. | | 422 | `idempotency_key_invalid` | Malformed key. | — | | 429 | `rate_limited` | Over 120 requests/minute for the organization. | Back off. Per-org, so more keys will not help. | | 500 | `internal_error` | Our fault. | Retry with backoff. Report if it persists. | | 503 | `unavailable` | A required subsystem is off. | x402 routes return this when the paid surface is not configured — closed rather than free. | ## 402 is not an error `402` on an `/x402/*` route is the protocol working. It carries signable payment terms, not a failure. See [x402](x402.md). ## Notable behaviours **404 hides existence.** Requesting an agent that belongs to another organization returns the same 404 as one that never existed. This is intentional: distinguishing them would let anyone enumerate other tenants' resources. **Revoked and invalid are the same 401.** For the same reason. **Upstream errors are not laundered.** When an email provider refuses to create an inbox, you get `400` with the provider's actual message. The alternative — swallowing it and storing a dead address — fails later, further away, and harder to diagnose. **Bookkeeping never fails a request.** If a payment settles but the ledger write fails, the request still returns `200`. The action happened and the money moved; reporting failure would be the false answer. ## Retrying safely Unsafe `POST`s accept an `Idempotency-Key` header. Send the same key on a retry and you get the **first** request's response instead of a second agent, a second wallet, or a second payment. ```sh curl -X POST $BASE/agents \ -H "Authorization: Bearer $INCARNA_KEY" \ -H 'Idempotency-Key: 8f14e45f-ea6a-4f7e-9c1b-1a2b3c4d5e6f' \ -H 'Content-Type: application/json' \ -d '{"name":"..."}' ``` Rules worth knowing: - Records expire after **24 hours**. - A key is bound to the body it first saw. Reusing it with a different body is `422 idempotency_key_reuse`, not a silent replay. - Concurrency is resolved in the database, so two simultaneous retries cannot both do the work. The loser gets `409 idempotency_in_progress`. ## Degraded is not an error An agent in `degraded` returns `200` like any other. It means a provisioning job ran out of retries — recoverable, and it recovers on its own. See the [lifecycle](concepts.md#status-lifecycle).