Skip to documentation
Documentation

Partner deployments

Deploy an agent for each of your users from your own backend, over REST or MCP.

A partner deployment lets your product create agents in your Keiki organization on behalf of your own users. Your backend calls the developer API (or an MCP client calls the same tools), names the user with an external_ref you choose, and Keiki creates the agent, connects the channels you asked for, and bills them to your organization's plan. Your users never need a Keiki account: you decide what each of them sees.

Every deployment lives in your organization. external_ref is unique per organization, so one user maps to one agent, and repeating a request updates that agent rather than creating another.

Authenticate

Create an org-wide API key (one not bound to a single agent) in the dashboard and send it as Authorization: Bearer <key>. Creating and deleting deployments needs the deployments:write scope; reading needs deployments:read or deployments:write. A * key does everything. Agent-bound keys receive 403.

The organization is always the one the key belongs to. Nothing in the request can select another.

Endpoints

EndpointWhat it does
POST /api/v1/developer/deploymentsCreate or update an agent and connect channels.
GET /api/v1/developer/deployments/:external_refRead one deployment.
DELETE /api/v1/developer/deployments/:external_refDisconnect channels and remove one deployment.
GET /api/v1/developer/trace-exportersList trace exporters configured for the organization.
POST /api/v1/developer/trace-exporters/:id/agentsAttach one agent to an existing trace exporter.
DELETE /api/v1/developer/trace-exporters/:id/agents/:agent_idDetach one agent from a trace exporter.

Create or update

JSON
{
  "external_ref": "user_123",
  "agent": { "template": "dental-front-desk", "name": "Front desk" },
  "channels": [{ "type": "imessage" }, { "type": "api" }],
  "webhook_url": "https://partner.example/deployments/events",
  "mcp_connections": [
    { "name": "Backstage", "endpoint": "https://backstage.example.com/mcp", "default_permission": "allow" }
  ],
  "tools": [
    {
      "name": "update_customer_plan",
      "description": "Change a customer's plan.",
      "executionType": "webhook",
      "webhookUrl": "https://partner.example/tools/update_customer_plan",
      "policy": { "approval": "ask", "requiredScopes": ["billing:write"] }
    }
  ]
}

A new deployment answers 201; a repeat of the same external_ref answers 200 with created: false. Both carry the same body:

JSON
{
  "deployment": {
    "externalRef": "user_123",
    "agent": { "id": "…", "name": "Front desk", "lineNumber": "+1…", "…": "…" },
    "channels": [
      { "type": "imessage", "status": "connected", "phoneNumber": "+1…" },
      { "type": "api", "status": "connected", "id": "…", "detail": { "passthrough": false } }
    ],
    "mcpConnections": [
      { "name": "Backstage", "endpoint": "https://backstage.example.com/mcp", "kind": "mcp",
        "authMode": "public", "headerNames": [], "status": "connected", "lastError": null,
        "defaultPermission": "allow" }
    ],
    "missingSecrets": []
  },
  "created": true,
  "channels": [
    { "type": "imessage", "status": "connected", "phoneNumber": "+1…" },
    { "type": "api", "status": "connected", "id": "…", "detail": { "passthrough": false } }
  ]
}

agent is either a template — { "template": "<id>", "name"?: "…" }, see list_agent_templates below — or an inline spec with name, model, system_prompt, max_steps (1–50), history_limit (1–200), features, harness (flue or hermes) and turn_deadline_ms — the wall-clock budget of one turn, 30 000–900 000 ms; past it the agent stops calling tools and answers with what it has. Omit it for the platform default (5 minutes); null clears an earlier value.

On an update, only the fields you send change. A template is applied once, when the agent is first created; later requests leave a customised agent alone.

webhook_url is optional. Omit it to keep the current value, send null to clear it. It must be a public https:// URL: http://, localhost, loopback, private and link-local addresses are rejected with 400, and an invalid URL leaves the agent untouched.

tools is optional. When present, including as an empty array, it replaces the deployment's developer tools: listed tools are enabled or upserted, while unlisted deployment tools are disabled. Tools authored in the dashboard are left enabled. Omitting tools leaves the current developer tools unchanged. Each tool may declare the same policy used by POST /api/v1/developer/tools.

tools field
name, descriptionrequired
executionTypewebhook or code
webhookUrl / coderequired for the matching execution type
inputSchema, timeoutoptional
policyoptional governed-action policy

Channels

Channels are additive: each request connects the channels it lists and leaves the ones it does not list alone. A channel that is already connected is reported as skipped, and its credentials are not rotated.

typeFields
imessagenone — Keiki provisions a dedicated line for the agent
telegrambot_token
whatsappaccess_token, phone_number_id, app_secret, verify_token, optional waba_id
slackbot_token, signing_secret, optional client_id, client_secret
agentmailregion, mode (create or existing), optional inbox_id (required when mode is existing), username, domain
apioptional passthrough (boolean) — turns on the API gateway

api is the exception to "already connected is skipped": there is nothing to rotate, so repeating it is how you change passthrough (the caller owns the prompt and tools, see passthrough mode; off by default). Its result carries the current value in detail.passthrough. Callers reach the gateway with your organization's developer key and identify the agent by name — see agent-api.md; each conversation there is identified by the x-conversation-id header, which is also what a later PUT /mcp-senders keys the conversation's own MCP headers by.

Each channel gets its own result with status of connected, needs_payment, error or skipped. A channel failure — a bad bot token, say — is reported in that channel's result and does not undo the agent or the other channels. Credentials you send are never returned.

MCP connections

mcp_connections is the agent's whole list of MCP servers and OpenAPI specs — the same list the dashboard's "Global MCP" page edits — up to ten, keyed by name:

Field
namerequired; the source a later PUT /mcp-senders refers to
endpointrequired; a public http(s) URL — the MCP server, or the OpenAPI spec for kind: "openapi"
kindmcp (default) or openapi
auth_modepublic (default), headers or oauth
headersagent-wide headers. For auth_mode: "headers" they are the credential and required when the connection is new; on any other MCP connection they are custom headers sent with every request (no Authorization under oauth, and none for an OpenAPI spec). Omit later to keep the stored values
icon_urloptional
default_permissionwhat the agent may do with a tool that has no explicit permission: allow (default), ask (a human approves each call) or deny

The list is declarative: a name that is already on the agent is updated (and reconnected only when its endpoint, kind, auth mode or headers changed), a name not in the list is removed, and an omitted mcp_connections field leaves the connections alone — like agent, send it only when you mean to change it. Every connection is connected before the request answers; the response's deployment.mcpConnections shows each one's status (connected, connected_no_tools, needs_auth, error with lastError) and header names, never values. Every request to an MCP server carries User-Agent: Keiki/1.0 (+https://onkeiki.com) unless the connection sets its own User-Agent; headers the transport sets itself (Host, Content-Type, Accept, Mcp-Session-Id, …) are rejected. A connection that cannot be reached is a 502. The agent needs the sandbox and mcp features (agent.features) — the request is a 400 otherwise.

Which credential a connection is called with is a separate choice from defining it:

  • One credential for the whole agent — auth_mode: "headers" with headers here. Every conversation, on every channel, calls the source the same way.
  • One credential per end user — define the connection here as public (or leave it out entirely and let the first PUT /mcp-senders create it), then set each user's headers through PUT /mcp-senders, keyed by the Slack user or, on the API gateway, by the conversation's x-conversation-id. That call works at any time — right after this deployment request, or when the user links their account weeks later — and is the only way headers reach an MCP call on a user's behalf: the gateway never forwards headers from the chat request itself.

Billing

Deployments bill your organization's existing plan; there is no per-user billing to set up. A sandbox organization that requests channels receives 402 with code: "plan_required" before any agent is created. A channel that needs a payment checkout answers status: "needs_payment" with a checkoutUrl, while the deployment request itself still succeeds. An iMessage line is a recurring add-on; deleting the deployment (or its last dedicated line) cancels the add-on before the number is released, and a billing failure keeps the deployment rather than leaving you paying for a line you no longer have.

Read and delete

GET returns the same deployment object as a create, without any provider credentials. DELETE disconnects every channel, releases an iMessage line and removes the agent; it answers { "ok": true }, and 404 once the deployment is gone. Both answer 404 for an external_ref that belongs to another organization.

Over MCP

The same operations are exposed as tools on the Keiki MCP server for owner and admin sessions of the organization: deploy_agent (same body as the POST), get_deployment, delete_deployment, list_agent_templates and list_channel_types. deploy_agent is marked destructive because an imessage channel can purchase a line.

Example

examples/partner-deploy/deploy.ts in the repository is a complete script: set KEIKI_API_KEY and KEIKI_BASE_URL, then bun run deploy.ts. The OpenAPI document served by the platform describes every field.

Trace export

Trace exporters are created and configured in Settings → Traces export. Use an org-wide API key with trace_exporters:read or trace_exporters:write to list them and find the exporter id; attaching and detaching require trace_exporters:write. Agent-bound keys are not accepted, and * grants both operations.

After deploying an agent, attach it with its external_ref:

HTTP
POST /api/v1/developer/trace-exporters/<exporter-id>/agents
Authorization: Bearer <key>
Content-Type: application/json

{"external_ref":"user_123"}

The body can instead contain {"agent_id":"<agent-id>"}. The operation is additive and idempotent: it does not replace the exporter's name, format, endpoint, headers, or other attached agents. GET /api/v1/developer/trace-exporters returns exporter metadata and header names only; secret header values are never returned.

Saving the exporter in Settings preserves agents attached through this API while the form was open.

Detach just that agent with DELETE /api/v1/developer/trace-exporters/<exporter-id>/agents/<agent-id>. Detaching an agent that was not attached succeeds without changing other agents. A malformed attach body returns 400; an exporter outside the organization returns 404 Trace exporter not found, and an unknown deployment or agent returns 404 Agent not found.

Per-sender MCP credentials

When one of the deployment's MCP connections authorizes each end user separately, the partner's backend can set the headers the agent calls it with on behalf of one sender: a Slack user (keyed by the Slack team and user), or an API-gateway conversation (keyed by the conversation_id the partner's client sends on its OpenAI/Anthropic-compatible calls — the only end-user identity the gateway has, and one the partner already maps to a user of their own). The credential is always scoped to the connection's name, and the agent is always the deployment's own.

EndpointWhat it does
PUT /api/v1/developer/deployments/:external_ref/mcp-sendersSet or replace one sender's headers.
DELETE /api/v1/developer/deployments/:external_ref/mcp-senders?source=…&channel=slack&team_id=…&user_id=…Revoke a Slack sender's.
DELETE /api/v1/developer/deployments/:external_ref/mcp-senders?source=…&channel=api&conversation_id=…Revoke an API conversation's.
GET /api/v1/developer/deployments/:external_ref/mcp-sendersList entries (header names only, never values).
JSON
{
  "source": "Backstage",
  "channel": "slack",
  "team_id": "T_ACME",
  "user_id": "U_CMO",
  "headers": { "Authorization": "Bearer <token issued to U_CMO>" }
}
JSON
{
  "source": "Backstage",
  "channel": "api",
  "conversation_id": "conv_8f2a",
  "headers": { "Authorization": "Bearer <token issued to the user behind conv_8f2a>" }
}

The conversation_id is the value of the x-conversation-id header the partner's client sends on its OpenAI- or Anthropic-compatible calls (see agent-api.md), so the partner chooses it and already knows which of their users it stands for — the natural choice is one id per user, or per user session, minted by the partner's backend. When the client did not send one, the platform minted a UUID and returned it in the response's x-conversation-id header; the partner must keep sending that id for the entry to apply to the user's later calls. GET …/mcp-senders lists the ids that already hold an entry.

If the deployment has no MCP connection named source yet, the PUT can create it: add a connection object with the connection's endpoint (and kind: "openapi" when it is an OpenAPI spec URL rather than an MCP server; icon_url is optional). The connection is created public — it holds no agent-wide credential, since every credential to it belongs to a sender — and connected before the sender's headers are stored; the response says whether it was created ({ "ok": true, "created": true }). Without connection, an unknown source is a 404 and nothing is stored. A connection for a source that already exists is ignored. A connection that cannot be reached is a 502, is not kept, and nothing is stored — fix the endpoint and retry; the deployment's ten-connection limit applies (400), as does one connection per endpoint (409 when another name already points at it).

JSON
{
  "source": "Backstage",
  "channel": "api",
  "conversation_id": "conv_8f2a",
  "connection": { "endpoint": "https://backstage.example.com/mcp" },
  "headers": { "Authorization": "Bearer <token issued to the user behind conv_8f2a>" }
}

Once any sender of a connection has an entry, every Slack turn runs its MCP calls on a daemon that belongs to the message's author: that connection is called with the author's headers, or with none when they have no entry (so the server's own 401 is what the agent sees; a server that refuses to even connect unauthenticated is simply absent for that sender), while the deployment's other connections keep their agent-wide credentials. A shared thread never shares a credential, and the model cannot read, set or pick one — the sender comes from the Slack event, not from the conversation. A Slack turn the event does not attribute to a user runs as a sender with no entry, never with the agent's own credentials. OAuth-authorized connections are not available on those turns.

API-gateway turns behave the same way, with the conversation in the user's place: every call of a conversation runs on that conversation's own daemon, under the headers stored for its conversation_id or none, and the id comes from the request the platform authenticated, not from the model. A conversation with no entry reaches the source with no credential, so the source's own 401 is what tells the agent to have the user link their account. As on Slack, the headers never appear in tool arguments, prompts or traces.

Typical Backstage flow: a user's first call returns the server's 401/connect prompt → the partner links the account and PUTs the bearer → the user's next turn in the same thread runs under it; DELETE revokes it from the next turn on.

Turn traces

Every turn — answered, failed or abandoned — is one trace, read with GET /api/v1/traces/:traceId (GET /api/v1/traces?status=error lists the failed ones; see cloud-agents.md). The answer is { trace, spans }.

trace is the turn: traceId, agentId, phone (the conversation's identity — an api:<agent id>:<conversation id> string for a gateway conversation, the Slack thread for Slack, a phone number for SMS), startedAt, endedAt, durationMs, model, tokensIn, tokensOut, totalSteps, costUsd, status and metadata. status is running while the turn is live, completed when it answered, error when it did not; on an error trace the top-level error string says why. The metadata carries harness (flue or hermes), channel, the flat channel identifiers and the inbound text; a passthrough gateway turn adds passthrough: true, a turn that continued a parked one adds resumedFromTraceId and resumedAtStep.

  • API gateway — metadata.channel is api; the conversation id is the last segment of phone, the same value as the request's x-conversation-id and the conversation_id of a PUT /mcp-senders. A conversation is one trace per request: a client-tool round trip (the turn suspends on your tools, you answer with tool_results) is a new trace pointing back through resumedFromTraceId.
  • Slack — metadata.slack is { teamId, channelId, threadTs, requesterUserId, inboundText, messageTs } — the requester being the Slack user the event attributed the message to (null when it named none), and messageTs the ts of the last inbound message. The same values are also present as flat fields (teamId, channelId, threadTs, userId, inboundText, messageId).

spans are the turn's steps, each with name, kind (llm, tool, retrieval, generation or internal), parentSpanId, startedAt, durationMs, status, input, output, metadata and, on a failed span, error. A model call is an llm span named chat_completion_step_<n>; each tool the model called in that step is a tool span named tool:<tool name> with the arguments as input, the result as output and metadata.{toolName, toolCallId, step}; the platform's own decisions are internal spans (suspended_on_client_tools, all_tool_calls_failed, final_answer_after_max_steps, …). Payloads over 64 kB are shortened evenly and wrapped as { "…": <bytes>, value }, so keys and shape survive; metadata past 32 kB is shortened the same way. Nothing a partner supplied as a credential — channel tokens, agent-wide MCP headers, per-sender MCP headers — is ever written to a span or the trace's metadata.

Reading a failed turn

A turn fails in one of three ways, and each reads differently:

What happenedtrace.status / trace.errorSpans
The turn threw — the model call failed, a deadline passed, the client abortederror, the exception (an abort is a one-line message, other failures carry a stack)the last span is the one that was running; earlier ones are complete
The process running the turn diederror, "The process running this turn stopped before it finished" (set once the process's liveness lease, renewed every 20 s, has lapsed)every span still running is closed as error with the same message
A tool failed but the turn went oncompletedthe tool:<name> span is status: error with error set; the model saw that error as the tool's result and answered around it

So a completed trace with failed tool: spans is a turn the agent recovered from, an error trace is one the caller saw fail (a 500 on the gateway, no reply on Slack). The internal spans between the two tell you which policy ended it: all_tool_calls_failed and stopped_on_repeated_tool_failure are error, final_answer_after_max_steps is the model being asked to wrap up, stopped_on_repeated_delivery is a turn that kept sending messages with nothing in between, and stopped_on_empty_reply is the model sending an empty message — it chose to say nothing, so nothing is sent and the turn ends there. A turn cut off by its step budget is only asked for a closing answer when every message it sent went out before it did any work (an "on it"); once it has sent a message after working, that message is its answer.

MCP call spans

Every mcp_execute / mcp_resume call is a tool:mcp_execute / tool:mcp_resume span whose input is the code the model ran and whose output is what came back. Beneath it, each call the code made to a connected source is its own child tool span, named mcp:<tool path> (for example mcp:crm.user.default.create), with metadata.toolName, the call's arguments as input, its result as output, the daemon's own start time and duration, and status: error plus a message when the source rejected it — so a 401 from a sender who has no credential shows up as one failed child of an otherwise normal step. Arguments and results larger than 8 000 characters of JSON are truncated; headers are never part of either.