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
| Endpoint | What it does |
|---|---|
POST /api/v1/developer/deployments | Create or update an agent and connect channels. |
GET /api/v1/developer/deployments/:external_ref | Read one deployment. |
DELETE /api/v1/developer/deployments/:external_ref | Disconnect channels and remove one deployment. |
GET /api/v1/developer/trace-exporters | List trace exporters configured for the organization. |
POST /api/v1/developer/trace-exporters/:id/agents | Attach one agent to an existing trace exporter. |
DELETE /api/v1/developer/trace-exporters/:id/agents/:agent_id | Detach one agent from a trace exporter. |
Create or update
{
"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:
{
"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, description | required |
executionType | webhook or code |
webhookUrl / code | required for the matching execution type |
inputSchema, timeout | optional |
policy | optional 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.
type | Fields |
|---|---|
imessage | none — Keiki provisions a dedicated line for the agent |
telegram | bot_token |
whatsapp | access_token, phone_number_id, app_secret, verify_token, optional waba_id |
slack | bot_token, signing_secret, optional client_id, client_secret |
agentmail | region, mode (create or existing), optional inbox_id (required when mode is existing), username, domain |
api | optional 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 | |
|---|---|
name | required; the source a later PUT /mcp-senders refers to |
endpoint | required; a public http(s) URL — the MCP server, or the OpenAPI spec for kind: "openapi" |
kind | mcp (default) or openapi |
auth_mode | public (default), headers or oauth |
headers | agent-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_url | optional |
default_permission | what 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"withheadershere. 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 firstPUT /mcp-senderscreate it), then set each user's headers throughPUT /mcp-senders, keyed by the Slack user or, on the API gateway, by the conversation'sx-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:
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.
| Endpoint | What it does |
|---|---|
PUT /api/v1/developer/deployments/:external_ref/mcp-senders | Set 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-senders | List entries (header names only, never values). |
{
"source": "Backstage",
"channel": "slack",
"team_id": "T_ACME",
"user_id": "U_CMO",
"headers": { "Authorization": "Bearer <token issued to U_CMO>" }
}{
"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).
{
"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.channelisapi; the conversation id is the last segment ofphone, the same value as the request'sx-conversation-idand theconversation_idof aPUT /mcp-senders. A conversation is one trace per request: a client-tool round trip (the turn suspends on your tools, you answer withtool_results) is a new trace pointing back throughresumedFromTraceId. - Slack —
metadata.slackis{ teamId, channelId, threadTs, requesterUserId, inboundText, messageTs }— the requester being the Slack user the event attributed the message to (nullwhen it named none), andmessageTsthetsof 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 happened | trace.status / trace.error | Spans |
|---|---|---|
| The turn threw — the model call failed, a deadline passed, the client aborted | error, 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 died | error, "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 on | completed | the 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.