External harness
Run an agent's turn loop in your own application while Keiki keeps channel ingress and delivery.
The external harness runs the model loop in an application you control. Keiki continues
to receive channel messages, apply steering and turn-slot limits, provide tools, deliver
messages, and record the turn trace. For each turn it sends a signed request to your app,
then waits for your app to report the result.
Configure an agent
Set the agent's harness to external and configure its connection with the developer API:
{
"harness": "external",
"harness_config": {
"mode": "push",
"dispatchUrl": "https://agent.example.com/keiki/turn",
"timeoutMs": 600000
}
}mode is push, relay, or inbox. Push mode posts directly to dispatchUrl; relay mode
queues the signed request for a connected relay to forward. Inbox mode queues work for an
outside agent to claim when it polls, and needs neither a dispatch URL nor a signing secret.
timeoutMs overrides the turn deadline, up to ten minutes. The default is ten minutes.
Push and relay apps use the signing secret returned by
POST /api/v1/developer/agents/:agentId/harness/signing-secret; the returned whsec_...
value is shown once and should be stored in your app's secret manager. Inbox pollers
authenticate with a harness API key instead.
A multi-agent handler can resolve the secret by agentId; a missing match should
reject the push with 401 unknown_agent.
Push through a tunnel
Run your HTTP server first, then start the shared tunnel supervisor with the agent's API key and the port your server listens on:
curl -fsSL https://<your-keiki-host>/api/v1/harness/tunnel.sh \
| KEIKI_API_KEY=sk_live_… PORT=8789 shTUNNEL defaults to cloudflared; set TUNNEL=ngrok to use ngrok instead, and optionally
provide NGROK_AUTHTOKEN. TURN_PATH appends a path such as /keiki/turn to the public
URL. HARNESS defaults to external; Hermes installs use HARNESS=hermes. KEIKI_HOME
defaults to ~/.keiki.
The script verifies the key and agent harness, then warns if nothing is listening on PORT
yet; it does not require the local server to be ready before starting a detached supervisor
that manages only the tunnel, not your app. Its PID, log, state and persisted supervisor
script are isolated under ~/.keiki/tunnels/<agent id>/. It registers the public URL in Push
mode while preserving the other harness settings. If the tunnel exits, it restarts with
exponential backoff from 2 to 60 seconds and updates the URL only when it changes. A failed
registration is retried with backoff. Re-running the command validates the setup and
downloads the supervisor before stopping the previous one.
Stop it explicitly with the same agent API key:
curl -fsSL https://<your-keiki-host>/api/v1/harness/tunnel.sh \
| KEIKI_API_KEY=sk_live_… KEIKI_TUNNEL_STOP=1 shLogs are at ~/.keiki/tunnels/<agent id>/tunnel.log. Generate a signing secret in the
dashboard and configure your app to verify Keiki-Signature before handling turns.
Push turns are synchronous: if the endpoint is down, the turn fails rather than being queued. The agent page probes the endpoint and shows the latest delivery outcome.
Turn request
Keiki sends a JSON keiki.turn payload with version: 1. It includes the turn and agent
IDs, conversation ID, channel envelope, inbound messages, bounded history, structured
context, prompt instructions, held-back/silent/quiet-loop flags, and a deadline. The
keiki object provides the platform API base, the turn-scoped URL, and the MCP URL.
Provider credentials and platform API keys are not included. The messages array
includes every current inbound message; an entry excluded from prompt guarding and
inbound recording is marked with silent: true.
When prompt guarding is enabled, deterministic Shield rule matches are refused before
the app receives the turn. Other flagged messages are delivered with their original
text and screening: { "flagged": true }, so the app can warn its model while still
answering ordinary replies normally. The Flue adapter adds that warning automatically.
Requests include Keiki-Turn-Id, Keiki-Turn-Token, and Keiki-Signature headers. The
signature is HMAC-SHA256 over the exact raw request body, prefixed by the Unix timestamp
and a period:
Keiki-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA256(secret, "<unix-seconds>.<raw-body>")>Verify the signature against the raw bytes before parsing or acting on the body. The
Keiki-Turn-Token is a short-lived bearer token for that turn's routes and should not be
logged or reused for another turn. Return any 2xx response once the request is accepted;
the model loop may continue asynchronously.
Runtimes that cannot carry the dispatch token between processes can instead sign a short-lived
turn credential with the agent signing secret and use it as the bearer for the turn API and
MCP endpoint. The ht_ token remains available to runtimes that can forward the dispatch
header. The credential is bound to the agent and trace ID and expires after five minutes:
Authorization: Bearer kts1.<payloadB64>.<sigHex>
payloadB64 = base64url(JSON.stringify({ a: agentId, t: turnId, ts: unixSeconds }))
sigHex = hex(HMAC-SHA256(signingSecret, `keiki-turn-auth.v1.${payloadB64}`))Use signTurnAuth from @onkeiki/channels to create it. Do not log either bearer or the
signing secret.
Mid-turn arrivals
In push mode, the external harness can deliver messages that arrive during an eligible
append turn to the running app. It polls for arrivals about once a second and sends a
signed keiki.turn.arrival request with the same raw-body signature headers as a normal
dispatch, plus a 1-based sequence number for that turn. The payload includes the turn and
conversation IDs, the inbound messages, and non-secret binding context:
{
"type": "keiki.turn.arrival",
"version": 1,
"turnId": "turn-123",
"agentId": "agent-456",
"conversationId": "+15555550123",
"heldBack": false,
"channel": "sms",
"keiki": {
"apiBase": "https://keiki.example/api/v1",
"turnUrl": "https://keiki.example/api/v1/turns/turn-123",
"mcpUrl": "https://keiki.example/api/v1/harness/mcp"
},
"sequence": 1,
"messages": [{ "text": "one more detail", "messageId": "message-789" }]
}The signed request includes the same Keiki-Turn-Token header as the turn's original
dispatch, alongside Keiki-Signature and Keiki-Turn-Id. The token is never included in the
arrival body; adapters that steer an active turn must preserve its turn authentication.
Do not log or persist the token.
Push delivery is also persisted for pull-based runtimes. The push and pull APIs carry the same screened wire messages for each accepted batch. An app may acknowledge the push without processing it and pull between its own model steps:
GET {turnUrl}/arrivals?after=0
Authorization: Bearer kts1.…after is a non-negative sequence cursor and defaults to 0. The response contains up to
50 batches ordered by sequence:
{
"arrivals": [{
"sequence": 1,
"messages": [{ "text": "one more detail", "messageId": "message-789" }]
}]
}Use the highest returned sequence as the next cursor. Only batches accepted by push dispatch are pullable; rejected batches return to steering. The endpoint requires an open turn credential.
Report arrivals the app processed in its /complete request; on a failed turn, report only
those already answered. A sequence is answered when a /messages send returns ok or
turn_closed after that arrival was processed. Completed turns should report every processed
arrival:
{ "status": "completed", "consumedArrivals": [1, 2] }The field is optional; omitting it means no arrivals were consumed. An app that consumes an arrival but omits its sequence will cause Keiki to put those messages back at the front of the pending queue, where a later turn answers them. Unconsumed batches and batches rejected by the app after retries are also returned to the queue. Returned messages are not written to turn history until a later turn takes them. Relay-mode turns do not take arrivals.
If an arrival reaches the app after the turn's completion boundary but before Keiki receives
/complete, the framework may start a new run against the closing Keiki turn. The app must
not report that arrival as consumed; its turn API calls can return 409 turn_closed, and
Keiki requeues it for the next turn. Flue adapters keep the turn binding until the instance's
queued and running submissions settle, so an arrival already queued for that instance is
handled before this completion boundary.
For the Eve extension, see the Eve integration guide. For Flue 2.x applications, see the Flue integration guide. For AI SDK 7 apps, see the AI SDK integration guide.
Turn-scoped API
Use the turnUrl from the payload. Every route accepts either Authorization: Bearer ht_...
with the token from Keiki-Turn-Token, or the signed kts1.… credential above. The
URL's turn ID must match the credential.
| Request | Purpose |
|---|---|
POST {turnUrl}/messages | Deliver { "text": "...", "idempotencyKey": "..." }; the key is optional. |
POST {turnUrl}/stream | Send newline-delimited JSON events: text-delta with text, and message-break. |
POST {turnUrl}/typing | Ask Keiki to show typing where the channel supports it. |
POST {turnUrl}/lease | Refresh a live claim for a long-running turn. |
GET {turnUrl}/arrivals?after=0 | Pull up to 50 durable arrival batches after a sequence cursor. |
POST {turnUrl}/complete | Settle the turn with status: "completed" or "failed", optional error, and optional usage. |
Messages on normal channel turns are delivered by Keiki and recorded in the trace. The
stream endpoint appends live to an active Slack stream when available; otherwise it
buffers text and delivers paragraphs at message breaks or blank-line boundaries. Keiki
never sends twice under one message idempotency key. Reusing a successfully processed
key returns a duplicate acknowledgement without sending it again. After any delivery
error, /messages returns 502 with retryable: false; the delivery outcome is unknown.
A retry with that key returns 409 with previous_attempt_failed. To try again anyway,
send a new key and accept that the contact might receive the message twice.
Held-back turns
Playground, internally steered, and agent-dispatch turns are held back: messages sent to
the turn API are captured as the turn's answer and are not delivered to the contact.
Deterministic prompt-guard rule matches are refused before they reach the app. Other
flagged messages are delivered with screening.flagged so the app can warn its model;
the Flue adapter adds the warning automatically. The payload's heldBack, silent, and
quietLoop fields let the app apply the same distinction in its own loop.
Completion and errors
Complete the turn after all messages and tool calls have settled. Repeated completion requests are acknowledged as duplicates. Keiki releases the active turn token and slot after observing the outcome, cancellation, or deadline.
Push-mode cancellation is a signed keiki.turn.cancelled request with the turn,
agent, and conversation identity:
{ "type": "keiki.turn.cancelled", "turnId": "…", "agentId": "…", "conversationId": "…" }Dispatch failures are represented on the trace with structured codes, including
signing_secret_missing, no_dispatch_url, unreachable, signature_rejected,
app_rejected, relay_offline, turn_timeout, app_error, and cancelled. Check the
trace for the associated error and remediation hint.
Relay mode
Relay mode uses the harness queue rather than posting directly to dispatchUrl. A
connected relay authenticates with a harness API key, claims work from
GET /api/v1/harness/turns/next, forwards the raw body and signature headers to the
configured app, and reports the dispatch result through the harness completion route.
The app then uses the same turn-scoped API as in push mode.
Inbox mode
Inbox mode is for an external agent that cannot receive pushes or is not always online.
Authenticate with a harness API key and long-poll
GET /api/v1/harness/inbox/next?wait=25 (the wait may be 0–30 seconds). A response is
{ "turn": null } when the wait expires, or { "turn": { ... } } with the turn, deadline,
turnToken, and turn-scoped URL. Run the model loop, send each contact-facing reply through
POST {turn.keiki.turnUrl}/messages, and complete the claimed turn through
POST {turn.keiki.turnUrl}/complete. Give each message an idempotencyKey that is stable
for that action.
Claims are leased. If an agent stops before completing a turn, the lease expires and a later
poll can reclaim it with a rotated turn token; message idempotency state survives the reclaim.
For a long-running turn, refresh its live lease with POST {turn.keiki.turnUrl}/lease and the
turn token; the response includes the extended leaseExpiresAt.
Only one turn for a conversation can be active at once. When a fresh claim is made, pending
turns for that same conversation are merged into it in arrival order. Other conversations
remain independently claimable.
Nothing is sent to contacts while the agent is offline: ordinary inbox turns stay queued
until a poller claims them. Test, steer, and other synchronous turns require a recent poll;
otherwise the request fails with inbox_offline.
Connect dot
Give dot the harness API key and this instruction:
Poll GET /api/v1/harness/inbox/next?wait=25 with Authorization: Bearer <HARNESS_API_KEY>.
For every returned turn, run the agent on its messages. Send replies only through
POST <turn.keiki.turnUrl>/messages using Authorization: Bearer <turn.turnToken> and a stable
idempotencyKey for each action. Then POST <turn.keiki.turnUrl>/complete with
{"status":"completed"} (or {"status":"failed","error":"..."}). Complete every claimed turn;
refresh the turn lease before it expires if the agent needs more time.
if the process restarts, resume by polling again and use the newly returned turn token.
Never send a contact message outside the turn API.