Interactions and continuations ​
An Agent interaction is an intentional, JSON-safe boundary where one run phase ends and control returns to the application. Anvia v1 supports two interaction types:
tool-approvalasks whether one validated tool call may execute;tool-questionasks for structured human answers before the tool call continues.
generate() and the terminal event from stream() return the same interaction outcome. It contains the public interaction request plus an opaque continuation that trusted server code can use to start a linked phase.
Handle an approval ​
let result = await agent.generate({ prompt: 'Refund order A-100.' })
while (result.type === 'interaction') {
if (result.interaction.type !== 'tool-approval') {
throw new Error(`Unsupported interaction: ${result.interaction.type}`)
}
const decision = await approvals.decide({
interactionId: result.interaction.id,
toolName: result.interaction.toolName,
input: result.interaction.input,
reason: result.interaction.reason,
})
result = await agent.resume(
result.continuation,
{
type: 'tool-approval',
approved: decision.approved,
reason: decision.reason,
},
)
}
if (result.type === 'response') console.log(result.output)The continued phase receives a new runId. Its resumedFrom link contains the source run and interaction IDs so traces and application records can reconstruct the chain.
Ask structured questions ​
import { createQuestionTool } from '@anvia/core/tool'
const askOperator = createQuestionTool({
name: 'ask_operator',
description: 'Ask the operator for missing details before continuing.',
})
const agent = new Agent({
id: 'support-escalation',
model,
instructions: 'Use ask_operator when priority or delivery channel is missing.',
tools: [askOperator],
})The model supplies one or more prompts with a stable ID, text, optional choices, and optional custom-answer support. Continue a question with one answer for every requested ID:
if (result.type === 'interaction' && result.interaction.type === 'tool-question') {
const continued = await agent.resume(
result.continuation,
{
type: 'tool-question',
answers: result.interaction.questions.map((question) => ({
questionId: question.id,
value: answersById[question.id],
})),
},
)
}Core rejects missing, duplicate, or undeclared choice answers when custom input is disabled.
Continue a stream ​
Consume a stream through its terminal outcome. When it yields an interaction, resume with agent.stream({ continuation, response }):
const first = agent.stream({ prompt: message })
let pending: Extract<Awaited<typeof first.result>, { type: 'interaction' }> | undefined
for await (const event of first) {
if (event.type === 'interaction') pending = event
}
if (pending?.interaction.type === 'tool-approval') {
const next = agent.stream({
continuation: pending.continuation,
response: { type: 'tool-approval', approved: true },
})
for await (const event of next) {
if (event.type === 'text_delta') process.stdout.write(event.delta)
}
}Own persistence and claims ​
Continuations contain strict JSON, but they are trusted runtime state—not browser authorization tokens. Keep them server-side and store them with the authenticated actor, tenant, agent ID, interaction ID, expiry, and claim state.
Before continuing:
- authenticate and authorize the responder;
- atomically claim the still-pending interaction;
- verify the response type matches the request;
- call the originating agent with
{ continuation, response }; and - recheck current authorization and resource state inside the tool handler.
Core validates continuation integrity against the current Agent and tool catalog. It does not supply a durable continuation store, distributed lock, expiry policy, or exactly-once side-effect guarantee.
For browser applications, Client Protocol v3 represents responses as type: 'interaction_response'. useChat() exposes pending interactions and sends matching responses, while the server remains responsible for mapping the interaction ID to its protected continuation.
Continue with Tool approval, Server transport, or Studio approvals and questions.
Validate stored continuations and incoming responses ​
Import the public boundary APIs from @anvia/core/agent/interactions:
| API | Validates |
|---|---|
parseAgentContinuation(value) | Continuation envelope, interaction shape, and strict-JSON state. |
parseAgentInteractionRequest(value) | Approval/question request shape, IDs, and question definitions. |
parseAgentInteractionResponse(value) | Response shape, including nonblank question answers. |
assertAgentInteractionResponse(request, response) | Matching interaction type and exact question coverage; allowed choices when custom answers are disabled. |
The corresponding agentContinuationSchema, agentInteractionRequestSchema, and agentInteractionResponseSchema expose .parse() and .safeParse(). Parsers return deeply frozen values. Parsing a response alone does not match it to a pending request: the assertion rejects missing, duplicate, unknown-question, and disallowed-choice answers. For free-text questions, allowCustom: false is invalid; choice questions permit custom answers unless explicitly disabled.
This complete server handler declares its application dependencies. Implement load, authorize, and claim in your protected storage layer; resume must call the originating Agent:
import {
assertAgentInteractionResponse, parseAgentContinuation, parseAgentInteractionResponse,
type AgentContinuation, type AgentInteractionResponse,
} from '@anvia/core/agent/interactions'
type PendingRecord = {
continuation: unknown
ownerId: string
tenantId: string
expiresAt: number
revision: string
}
type Dependencies = {
load(id: string): Promise<PendingRecord>
authorize(actorId: string, pending: PendingRecord): Promise<void>
// Compare revision and pending status atomically; exactly one claimant succeeds.
claim(id: string, revision: string): Promise<boolean>
resume(continuation: AgentContinuation, response: AgentInteractionResponse): Promise<unknown>
}
export async function continueInteraction(
authenticatedActorId: string, interactionId: string, body: unknown, deps: Dependencies,
) {
const pending = await deps.load(interactionId)
await deps.authorize(authenticatedActorId, pending)
if (pending.expiresAt <= Date.now()) throw new Error('Interaction expired')
const continuation = parseAgentContinuation(pending.continuation)
if (continuation.interaction.id !== interactionId) throw new Error('Interaction ID mismatch')
const response = parseAgentInteractionResponse(body)
assertAgentInteractionResponse(continuation.interaction, response)
if (!await deps.claim(interactionId, pending.revision)) throw new Error('Interaction already claimed')
return deps.resume(continuation, response)
}Authenticate before calling the handler. authorize must check the current actor's access to the stored owner/tenant and operation; never take those fields from the response body. Claiming must also enforce current pending/expiry state in the same atomic operation, using revision to reject stale records. Resume failures need an application recovery policy and audit state; blindly releasing a claim may repeat a tool side effect.
A structurally valid continuation is not necessarily resumable: the originating Agent validates its opaque state against its identity and current tool catalog. Preserve that state unchanged and keep it server-side. Neither the schemas nor the matching assertion supply authorization, durable locking, expiry enforcement, or an exactly-once execution guarantee.