AI SDK integration
Run AI SDK 7 turns in your app while Keiki manages channels, arrivals, tools and delivery.
@onkeiki/ai-sdk connects an AI SDK 7 model loop to Keiki's external harness. Keiki
dispatches a signed turn request; the handler accepts it with 202, runs the model
asynchronously, and reports messages and completion back to Keiki. Keiki retains channel
delivery, MCP tools, cancellation, history and trace records.
Install
bun add @onkeiki/ai-sdk ai @ai-sdk/mcpOr run npx @onkeiki/cli init in your app to do this setup for you (see CLI).
Set the external harness signing secret as KEIKI_SIGNING_SECRET, or supply it through
signingSecret. The handler verifies the raw dispatch body and signs every turn API and MCP
request with a short-lived, agent-and-turn-bound kts1 credential. It does not keep or use
the dispatch ht_ token.
Serving several agents
Resolve the signing secret by the dispatch's agent ID:
const secrets: Record<string, string> = { agentA: secretA, agentB: secretB }
const handler = createKeikiHandler({
signingSecret: ({ agentId }) => secrets[agentId],
onTurn: async ({ messages }) => streamText({ model, messages }),
})Returning nothing rejects the push with 401 unknown_agent. The resolver runs
before the signature is verified, so use a local map or cache rather than making
a network call on every request. The AI SDK calls it on every push and each
signed turn API or MCP request.
Next.js
Use Vercel's waitUntil so the function can return the dispatch acknowledgement before the
model run completes:
import { createKeikiHandler } from '@onkeiki/ai-sdk'
import { waitUntil } from '@vercel/functions'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export const POST = createKeikiHandler({
waitUntil,
onTurn: async ({ instructions, messages, tools, prepareStep, abortSignal }) =>
streamText({
model: openai('gpt-4.1-mini'),
instructions,
messages,
tools: await tools(),
prepareStep,
abortSignal,
}),
})The signing secret defaults to process.env.KEIKI_SIGNING_SECRET. A provider-specific key
such as OPENAI_API_KEY remains in your app environment, never in Keiki's turn payload.
Cloudflare Workers
Pass the request's execution context to the handler:
import { createKeikiHandler } from '@onkeiki/ai-sdk'
import { streamText } from 'ai'
import { createOpenAI } from '@ai-sdk/openai'
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
return createKeikiHandler({
signingSecret: () => env.KEIKI_SIGNING_SECRET,
waitUntil: (promise) => ctx.waitUntil(promise),
onTurn: async ({ instructions, messages, tools, prepareStep, abortSignal }) =>
streamText({
model: openai('gpt-4.1-mini'),
instructions,
messages,
tools: await tools(),
prepareStep,
abortSignal,
}),
})(request)
},
}Keep provider keys and the Keiki signing secret in Worker secrets. A Worker instance's in-memory duplicate protection cannot coordinate with another instance.
Node and Hono
Without waitUntil, the handler starts the turn in the background. Use a persistent Node
server process so detached work can continue after the HTTP response:
import { Hono } from 'hono'
import { createKeikiHandler } from '@onkeiki/ai-sdk'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
const app = new Hono()
const keikiTurn = createKeikiHandler({
onTurn: async ({ instructions, messages, tools, prepareStep, abortSignal }) =>
streamText({
model: openai('gpt-4.1-mini'),
instructions,
messages,
tools: await tools(),
prepareStep,
abortSignal,
}),
})
app.post('/keiki/turn', (context) => keikiTurn(context.req.raw))
export default appTools and mid-turn arrivals
tools() creates an AI SDK MCP client for the turn's Keiki MCP URL. Each HTTP request is
signed independently. The handler closes the MCP client when that run settles.
prepareStep pulls arrivals since its last sequence and appends them to the AI SDK messages
as user messages. Pass it directly to streamText or generateText. If your app has its own
step preparation, compose both functions so the app sees the updated messages:
import { composeKeikiPrepareStep } from '@onkeiki/ai-sdk'
const onTurn = async (ctx) => {
const prepareStep = composeKeikiPrepareStep(
ctx.prepareStep,
async ({ messages }) => ({
messages: [...messages, ...(await loadAppSpecificMessages())],
}),
)
return streamText({
model: openai('gpt-4.1-mini'),
messages: ctx.messages,
tools: await ctx.tools(),
prepareStep,
abortSignal: ctx.abortSignal,
})
}Arrival pushes are acknowledged but do not mutate the running process; durable pulls at step boundaries are the source of truth. The pull endpoint returns the same screened message batch that Keiki sent to the push adapter.
Streaming, generation and cancellation
Return the result from streamText or generateText. The handler sends each text-producing
stream step as its own turn /stream request; non-text steps are not sent. Each
generateText step with text is sent through /messages with a step-specific idempotency
key. A stream request is not automatically retried when its delivery outcome is unknown.
Return void if your code delivered the answer itself through ctx.client.
Turn cancellation aborts a run owned by the same process. On serverless, Keiki also rejects
the next arrival pull for a closed turn; the handler aborts its model signal at that step
boundary. Cancellation cannot interrupt a provider call that does not honor AbortSignal.
The handler keeps a bounded in-memory duplicate set per handler instance. It does not provide cross-instance idempotency: a serverless retry routed to a different instance may start the same turn again. Completion retries use bounded 1s/5s/15s backoff; message and stream sends are not retried. Configure Keiki's external harness in push mode and use a persistent signing secret for the agent.