captain
v0.4.1
Published
A small, JSON-serializable workflow protocol and transport-neutral runtime.
Maintainers
Readme
captain
Captain v1 is a small, JSON-serializable workflow protocol. It ships four 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.captain/cli— a Bun-powered full-screen terminal host built with OpenTUI.
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.
Terminal CLI
Installing Captain exposes a captain executable. The core authoring and runtime modules support Node, but the full-screen CLI currently requires Bun 1.3 or newer.
bun add captain
bunx captain start ./my-workflow.js --name MarshallEvery named flag is added to the workflow's durable context. Workflow params are intentionally not supported by the CLI yet. captain start loads ./captain.js when it exists; pass --config <file> to use a different runtime config:
// captain.js
export default {
services: {
ai: {
async generate(input, { signal, update }) {
update({ message: 'Generating…' })
return { response: await generateWithMyProvider(input.prompt, { signal }) }
},
},
},
}The config may instead default-export a sync or async function receiving { cwd, env }. Without a config file, the CLI provides an OpenAI Responses API service configured by OPENAI_API_KEY, with optional OPENAI_MODEL, CAPTAIN_AI_MODEL, OPENAI_EFFORT, and OPENAI_BASE_URL values.
The TUI owns stdin and renders its alternate screen on stderr. Its completed result goes to stdout, so redirection and pipes remain clean. Strings and objects with a custom toString() are emitted directly; ordinary objects and arrays become pretty JSON.
bunx captain start ./write-readme.js > README.generated.md
bunx captain start ./scaffold.js | jq -r .files[0].content > index.jsAll asks yielded in one frame appear together as one scrollable form. Tab and Shift+Tab move between fields without losing answers. Enter advances from single-line and choice fields; on the final field it validates and submits the whole form. Multiline fields use Alt+Enter to advance while plain Enter adds a line. Enter also continues display-only pages. The persistent navigation controls use Ctrl+P for back and Ctrl+N for forward; Ctrl+O opens the workflow drawer, Ctrl+R retries failed work, and Ctrl+C exits.
Applications can embed the same behavior from captain/cli, supplying workflows and runtime options entirely in memory. Module calls return the raw workflow value, leaving output handling to the application:
import { createTui } from 'captain/cli'
import { scaffold, release } from './workflows.js'
const tui = createTui({
workflows: { scaffold, release },
runtimeOptions: {
services: { ai: { generate: myAiService } },
},
})
const result = await tui.run('scaffold', {
context: { name: 'Marshall' },
})
await handleResult(result)createTui() also accepts a caller-created Captain runtime, custom input and output streams, or a createUI() factory. formatResult() is exported separately for embedded CLIs that want Captain's built-in stdout policy. See examples/cli for realistic scaffolding, README, release-note, UI, and embedded-module workflows.
Workflow authoring
npm install captainimport { 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.responseString 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 typecheckThere 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:openaiIt defaults to gpt-5.6; set OPENAI_MODEL to override that choice. Live tests run only through test:openai; normal bun test and bun run check skip them even when OPENAI_API_KEY is exported. 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
