Skip to documentation
Documentation

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:

JSON
{
  "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:

Shell
curl -fsSL https://<your-keiki-host>/api/v1/harness/tunnel.sh \
  | KEIKI_API_KEY=sk_live_… PORT=8789 sh

TUNNEL 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:

Shell
curl -fsSL https://<your-keiki-host>/api/v1/harness/tunnel.sh \
  | KEIKI_API_KEY=sk_live_… KEIKI_TUNNEL_STOP=1 sh

Logs 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:

text
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:

text
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:

JSON
{
  "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:

HTTP
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:

JSON
{
  "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:

JSON
{ "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.

RequestPurpose
POST {turnUrl}/messagesDeliver { "text": "...", "idempotencyKey": "..." }; the key is optional.
POST {turnUrl}/streamSend newline-delimited JSON events: text-delta with text, and message-break.
POST {turnUrl}/typingAsk Keiki to show typing where the channel supports it.
POST {turnUrl}/leaseRefresh a live claim for a long-running turn.
GET {turnUrl}/arrivals?after=0Pull up to 50 durable arrival batches after a sequence cursor.
POST {turnUrl}/completeSettle 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:

JSON
{ "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:

text
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.