Core capabilities
Four capabilities: streaming chat, tool-call visualization, human approval, and one protocol across all four surfaces.
Streaming chat
Token-level incremental rendering. Three live-only event types (assistant.delta / reasoning.delta / tool.progress) go straight to the UI without persistence; the turn is finalized on disk by assistant.message when it ends.
All 27 events arrive over one SSE endpoint (GET /api/event, resuming from since=seq after a disconnect). Each surface folds the stream into UI state with the same applyEvent reducer — the protocol layer is runtime code, not a types package. Note the two "projections" differ: the UI projection is the per-surface reducer; the engine-side Projector projects model context (surface events → LlmMessage).
// packages/protocol/src/events.ts — 三分类是类型级事实,不是注释
export type LiveOnlyEventType =
| 'assistant.delta'
| 'reasoning.delta'
| 'tool.progress'
/** surface 事件强制带 surface:true(编译期纪律) */
export type SurfaceEventType = 'user.message' | 'assistant.message'
export type DurableEventType = Exclude<SparkEventType, LiveOnlyEventType>
// 词表共 27 种(EventSchemas 键数):durable 24 / live-only 3;surface 2
// 生成物 apps/docs/events.md 由 CI 重跑并 git diff --exit-code 校同步Tool call visualization
Every tool call is a collapsible execution block in the session flow: input, output, duration and error flag shown in full, with error codes on screen when things fail.
The tool state machine runs started → [progress] → completed. tool.completed carries isError and durationMs, both durable — replaying the file rebuilds exactly what was on screen, no extra instrumentation needed.
// packages/protocol/src/events.ts — 工具(状态机 started → [progress] → completed)
'tool.started': z.strictObject({
turnId: TurnIdSchema,
callId: CallIdSchema,
name: z.string(),
input: z.unknown(),
}),
'tool.progress': z.strictObject({
turnId: TurnIdSchema,
callId: CallIdSchema,
chunk: z.string(),
}), // live-only
'tool.completed': z.strictObject({
turnId: TurnIdSchema,
callId: CallIdSchema,
output: z.unknown(),
isError: z.boolean(),
durationMs: z.number().int().nonnegative(),
}),Human approval (fail-closed)
Write-class tools raise an approval card inline at the call site. Timeouts, errors and interrupts always settle to reject, never allow.
permission.asked carries requestId / action / resource / reason and persists durably; the user's reply lands as permission.resolved with exactly three values: once / always / reject. The engine settles timeouts to reject — deny-by-default is a code path, not a slogan. Approval events are log-only and never enter model history.
// packages/protocol/src/primitives.ts
export const PermissionReplySchema = z.enum(['once', 'always', 'reject'])
// packages/engine/src/permission/service.ts — 超时即拒绝(fail-closed)
timer: setTimeout(() => {
void this.settle(entry, false, 'reject', 'timeout')
}, this.deps.timeoutMs),
// timeoutMs 来自 ~/.spark/spark.json 的 engine.permissionTimeoutMs,缺省 300_000(5min)
// origin: 'reply' | 'timeout' | 'abort' | 'shutdown' | 'cascade' | 'mode-change'One protocol, four surfaces
Web, desktop, CLI, mobile and the mini app all share @spark/protocol: the event vocabulary, zod schemas, the applyEvent reducer and Transport live in one package — runtime code, not type declarations.
Transport is the only data-channel abstraction on the frontend; HttpTransport (SSE) and MockTransport are structurally mirrored, so development continues at full speed without a backend. @spark/sdk adds two sub-entries: the root entry speaks HTTP (zero engine dependency, browser-safe) and ./inprocess connects to the engine in-process (engine as an optional peer).
// packages/protocol/src/transport.ts — 接口面节选(全量 84 个方法)
export interface Transport {
/** 订阅事件流;返回退订函数 */
onEvent(handler: (e: SparkEventEnvelope) => void): () => void
sendMessage(sessionId: SessionId, text: string, opts?: SendMessageOptions): Promise<SubmitOutcome>
interrupt(sessionId: SessionId): Promise<void>
replyPermission(requestId: RequestId, reply: PermissionReply, feedback?: string): Promise<void>
/** GET /api/sessions/:id:meta + durable 事件(seq 升序——冷启动回放数据源) */
getSession(sessionId: SessionId, query?: SessionEventsQuery): Promise<SessionDto>
/** POST /api/pair:短码兑长效 token(移动端鉴权自举,ADR D24) */
redeemPair(body: PairRedeemBody): Promise<PairTokenDto>
dispose(): void
}
// 实现:HttpTransport(protocol,SSE)/ MockTransport(apps/web 开发态)