@looops/protocol
v0.0.1
Published
Wire protocol for Loops: event content shapes, tool interface, streaming handler types. Shared between agent runtime and frontend clients.
Maintainers
Readme
@looops/protocol
The wire protocol shared between Loops agent runtimes and frontend clients. Defines:
- The event envelope and content shapes that every session is built from.
- The tool interface (
LoopsTool) and theloopsTool()factory that produces AI-SDK-compatible tools with built-in approval gates. - The stream handler types the agent loop calls into.
This package contains types and one small factory function. It has no I/O, no storage, no networking. Every other Loops package depends on it.
Install
npm install @looops/protocolPeer requirements (already pulled in transitively): ai and @ai-sdk/provider-utils.
Subpath exports
The package is split into three entry points so frontends don't pay for backend dependencies:
| Import path | Contents | Pulls in ai SDK? |
| --- | --- | --- |
| @looops/protocol/events | Event content types, EVENT_TYPES, EventEnvelope, EventEnvelopeOf | No — pure types |
| @looops/protocol/tools | LoopsTool, LoopsToolSet, ApprovalRequiredError, loopsTool() | Yes |
| @looops/protocol/streaming | StreamHandlers, StreamResult, *Info shapes | No — pure types |
| @looops/protocol | Everything | Yes |
Frontends that only render an event log should import from @looops/protocol/events.
Events
A session is an ordered list of EventEnvelope records. Each envelope carries a type discriminant and a matching content payload.
import {
EVENT_TYPES,
type EventEnvelope,
type EventEnvelopeOf,
} from '@looops/protocol/events'
EVENT_TYPES
// readonly ['user-message', 'assistant-message', 'tool-call', 'tool-result',
// 'tool-error', 'trigger', 'loop-triggered', 'other-agent-message',
// 'user-abort', 'child-generation', 'generation-failure', 'reasoning']
function render(event: EventEnvelope) {
switch (event.type) {
case 'tool-call':
// event.content is narrowed to ToolCallContent
return event.content.toolName
case 'reasoning':
return event.content.text
// ...exhaustive
}
}
// Narrow at the type level when you know the variant up front:
type ToolCallEvent = EventEnvelopeOf<'tool-call'>Timestamps
EventEnvelope is generic over the timestamp type and defaults to string (ISO 8601, JSON-safe for the wire). Backends holding native Date values can parameterize:
type WireEvent = EventEnvelope // createdAt: string
type ServerEvent = EventEnvelope<Date> // createdAt: DateEvent kinds
| type | When emitted | content shape |
| --- | --- | --- |
| user-message | User submits a prompt | { text } |
| assistant-message | Model produces an assistant turn | { text } |
| tool-call | Model invokes a tool | { toolCallId, toolName, args, readOnly?, approvalStatus?, approvalDescription? } |
| tool-result | Tool returned a value | { toolCallId, toolName, result, output? } |
| tool-error | Tool threw or was rejected | { toolCallId, toolName, error, isUserRejection? } |
| reasoning | Thinking block from a reasoning model | { text, durationMs? } |
| trigger | External trigger (cron, webhook) started a generation | { triggerName, payload? } |
| loop-triggered | A child thread/loop was kicked off | { triggeredBy, parentGenerationId?, instructions? } |
| child-generation | Child generation finished, summary recorded on the parent | { item, index, total, durationMs, parentGenerationId, childGenerationId, ... } |
| other-agent-message | Another agent in a multi-agent setup spoke | { text } |
| user-abort | User stopped a running generation | { generationId } |
| generation-failure | Generation failed before completing | { error } |
Tools
loopsTool() is a drop-in replacement for the AI SDK's tool() that adds two things:
- Streaming output back to the UI via
onToolOutputDelta(delta). - Approval gates that pause the generation until the user confirms a tool call.
import { loopsTool } from '@looops/protocol/tools'
import { z } from 'zod'
export const writeFile = loopsTool({
name: 'write_file',
description: 'Write content to a file',
readOnly: false,
inputSchema: z.object({
path: z.string(),
content: z.string(),
}),
// Return a description string to require approval; null to auto-run.
// Only consulted when the runtime's policy is 'ask'.
requestApproval: ({ path }, ctx) =>
ctx.mode === 'unsupervised' ? null : `Write to \`${path}\`?`,
execute: async ({ path, content }, opts) => {
opts.onToolOutputDelta(`Writing ${content.length} bytes to ${path}…\n`)
await fs.writeFile(path, content)
return { ok: true }
},
})Approval policies
writeFile.withApproval(policy, modeName) returns a copy with the policy baked in. The runtime calls this before instantiating the tool for a given turn.
| Policy | Behavior |
| --- | --- |
| 'always_allow' | Run without prompting. |
| 'ask' | If requestApproval(input, ctx) returns a string, throw ApprovalRequiredError carrying that description. The runtime catches this and surfaces the call as a tool-call event with approvalStatus: 'pending_approval'. If requestApproval returns null (or is absent), use the default Run ${name}? description, or auto-run if there is no requestApproval. |
| 'blocked' | Currently equivalent to throwing in the runtime layer; this package only carries the type. |
import { ApprovalRequiredError } from '@looops/protocol/tools'
try {
await tool.makeTool(ctx).execute(input, callOptions)
} catch (err) {
if (err instanceof ApprovalRequiredError) {
await sessionStore.appendPendingApproval(err)
} else {
throw err
}
}Stream handlers
The agent loop calls into a StreamHandlers object as the model produces tokens, calls tools, and emits reasoning. Implementers persist these into a session store.
import type { StreamHandlers } from '@looops/protocol/streaming'
const handlers: StreamHandlers = {
onAssistantMessage: async ({ generationId }) => { /* … */ },
onAssistantMessageDelta: async ({ generationId, delta }) => { /* … */ },
onReasoningStart: async ({ generationId }) => { /* … */ },
onReasoningDelta: async ({ generationId, delta }) => { /* … */ },
onToolCall: async (info) => { /* … */ },
onToolResult: async (info) => { /* … */ },
onToolNotFound: async (info) => { /* … */ },
onToolError: async (info) => { /* … */ },
onToolOutputDelta: async (info) => { /* … */ },
onToolApprovalRequired: async (info) => { /* … */ },
}The exact runtime that calls these handlers will live in @looops/agent (forthcoming).
Stability
0.0.x. Shapes may shift while the surrounding packages are still being built. Once @looops/agent and @looops/sessions ship a stable release, the protocol will be pinned to 1.0 and follow semver strictly.
License
MIT
