captain
Captain v1 is a small, JSON-serializable workflow protocol. It ships three targets:
captain— workflow authoring withflow,phase,ask,ui, andai.captain/protocol— protocol version 1, Draft 2020-12 JSON Schema, generated TypeScript declarations, and conformance fixtures.captain/runtime— transport-neutral runtime glue for a host server.
Clients do not need a Captain SDK. Web, CLI, and iOS clients can render the frame JSON directly from the schema. Every yield is automatically one full-page frame and one submission scope; there are no page, form, or section UI types.
Workflow authoring
npm install captain
import { ai, ask, flow, phase, ui } from 'captain'
export const welcome = flow(function* () {
phase('Your profile')
const { name, plan } = yield [
ui.md`# Welcome`,
{
name: ask.text('Name'),
plan: ask.multiline('What are you building?'),
},
]
const summary = yield ai`Summarize ${name}'s plan: ${plan}`
yield ui.notice(String(summary.response), { tone: 'success' })
return { name, plan, summary }
})
Arrays determine presentation order. Object properties name value-producing results, including nested results. A directly yielded ask or service returns its value directly. ui(...) is only a composition convenience.
Workflows receive at most one parameter object. Hosts may also seed durable context before the first generator step:
export const welcome = flow(function* (params) {
this.name ??= yield ask.text('What is your name?', { key: 'name' })
yield ui.md`# Hello, ${this.name}! Welcome to ${params.product}.`
})
await runtime.start('welcome', {
params: { product: 'Captain' },
context: { name: 'Ada' },
})
Here the initial question is skipped. params is immutable invocation input by convention; context becomes durable workflow state through this, is saved with the session, and is never included in public snapshots. Both values must be JSON-compatible objects. Positional parameter arrays are not supported.
The exact v1 vocabulary is listed in Primitives.
AI authoring
ai is multiline prompting sugar for service.ai.generate. It uses the same automatic replay-stable service keys and produces the same canonical service effect:
const result = yield ai`
Create a character based on:
${params}
`.as({
type: 'object',
required: ['name', 'lore'],
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 5, maxLength: 10 },
lore: { type: 'string' },
},
})
const character = result.response
String interpolations remain text; other values are formatted as JSON. .as(schema) supplies responseSchema to the host AI service, and Captain validates output.response before completing the effect. Invalid structured output becomes a normal retryable service error. Model, reasoning effort, credentials, and provider-specific configuration belong to the host AI service. An explicit { key } remains available when intentional logical reuse is needed, but is not required.
Host services
Every runtime supplies an AI generation handler. It receives immutable input and returns output; progress is a separate replace-on-update JSON value.
import { createRuntime } from 'captain/runtime'
const runtime = createRuntime({
services: {
ai: {
async generate(input, { signal, update }) {
update({ message: 'Generating…' })
return { response: await generateText(input, { signal }) }
},
},
},
})
Services retain stable-key reuse, retries, cancellation, concurrent execution, restoration, and optional lifecycle renderers. Service renderers receive { status, input, progress, output, error } and return ordinary UI effects. Without a renderer, clients receive core.ui.service.
Minimal Bun API
The host owns routing, authentication, persistence, polling, and SSE. A minimal API can map directly to start, get, and act:
import { welcome } from './workflows/welcome.js'
runtime.register('welcome', welcome)
Bun.serve({
async fetch(request) {
const url = new URL(request.url)
const parts = url.pathname.split('/').filter(Boolean)
if (request.method === 'POST' && url.pathname === '/workflows/welcome') {
return Response.json(await runtime.start('welcome'))
}
if (request.method === 'GET' && parts[0] === 'workflows' && parts[2] === 'sessions') {
return Response.json(runtime.get(parts[1], parts[3]))
}
if (request.method === 'POST' && parts[0] === 'workflows' && parts[2] === 'sessions') {
return Response.json(await runtime.act(parts[1], parts[3], await request.json()))
}
return new Response('Not found', { status: 404 })
},
})
A waiting ask frame is submitted with answers keyed by ask key:
{
"action": "next",
"frame": "current-frame-id",
"revision": 3,
"answers": { "name-effect-key": "Ada" }
}
start, get, act, restoreSession, and interrupt return only the public SessionSnapshot. exportSession returns opaque host persistence data using captain-session version 3.
Protocol development
bun test
bun run check:protocol
bun run typecheck
There is also an opt-in integration test that runs the character workflow against the real OpenAI Responses API:
OPENAI_API_KEY=your-key bun run test:openai
It defaults to gpt-5.6; set OPENAI_MODEL to override that choice. Without an API key the integration test is skipped, including during the normal bun test run. The OpenAI SDK is a development dependency, and the package's files allowlist excludes the entire test/ directory from published artifacts.
Run bun run generate:protocol after changing src/protocol/schema.json. Scheduling, until, uploads, resources, notifications, capabilities, custom protocol extensions, and client SDKs remain post-v1.
Primitives
Ask
ask.textask.multilineask.emailask.secretask.urlask.telask.numberask.dateask.timeask.checkboxask.confirmask.choice
UI
ui.markdownui.headingui.noticeui.progressui.imageui.videoui.service(runtime-generated fallback)
Service
service.ai.generate