Agent SDK and React
Build durable agent flows with start, reconnect, Continue, client tools, completion state, and React hooks.
Installation
npm install @amarsia/sdkUse client.agent for reconnectable v2 agent turns. Existing client.conversation remains the non-breaking choice for ordinary v1 chat and streaming.
Quick start
import { amarsia } from "@amarsia/sdk"
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
})
const state = await client.agent.start({
content: [{ type: "text", text: "Review this account and recommend next steps." }],
})
console.log(state.conversationId, state.conversationStatus, state.messages)
client.agent.close()start() waits for the current turn, returns AgentState, loads persisted messages, and starts SDK-managed refresh. Customers do not need to implement transport or result-submission loops.
Authentication
Keep API keys on your backend for server-started agents. In a browser, use a workflow with authentication disabled and an origin allowlist, or route the request through your backend.
Never expose a long-lived API key in browser JavaScript. dangerouslyAllowBrowserApiKey acknowledges exposure; it does not protect the key.
Core operations
Start
client.agent.start(options) creates a conversation or starts another turn on a supplied active conversation.
| Option | Type | Required | Description |
|---|---|---|---|
content | MessageContent[] | Unless triggerId supplies it | Non-empty user content |
deploymentId | string | No | Overrides the deployment configured on the client |
conversationId | string | No | Active conversation to continue |
triggerId | string | No | Prepared trigger for the initial turn |
variables | object | No | Initial prompt variables |
meta | object | No | Initial searchable metadata |
historyLimit | number | No | Prior message pairs supplied to the model |
clientTools | AgentClientTools | No | Connected handlers and optional schemas |
pollIntervalMs | number | No | Internal refresh interval from 500–60,000 ms; default 2,000 |
messagePageSize | number | No | Initial and refresh message page size from 1–100; default 100 |
signal | AbortSignal | No | Aborts local lifecycle work |
You may pass triggerId when creating a conversation, or pass both triggerId and conversationId when continuing the conversation already linked to that trigger. The backend rejects mismatched sessions.
// First turn for a prepared trigger
const started = await client.agent.start({
triggerId: process.env.AMARSIA_TRIGGER_ID!,
})
// A later turn may carry both IDs for the same trigger session
await client.agent.start({
triggerId: process.env.AMARSIA_TRIGGER_ID!,
conversationId: started.conversationId!,
content: [{ type: "text", text: "Continue." }],
})Get
client.agent.get(conversationId, options) returns one server snapshot without opening polling or changing the controller's active conversation.
const snapshot = await client.agent.get(process.env.AMARSIA_CONVERSATION_ID!, {
page: 1,
pageSize: 25,
})
console.log(
snapshot.conversationStatus,
snapshot.runStatus,
snapshot.messages,
snapshot.pendingToolCalls,
)Use get() in backend jobs, status endpoints, and occasional checks. Its options are page, pageSize, and signal.
Open
client.agent.open(conversationId, options) reconstructs server state and invokes matching handlers for unresolved tools.
import { amarsia } from "@amarsia/sdk"
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
})
const unsubscribe = client.agent.subscribe((state) => {
console.log(state.status, state.runStatus, state.pendingToolCalls)
})
await client.agent.open(process.env.AMARSIA_CONVERSATION_ID!)
unsubscribe()
client.agent.close()open() loads the newest message page and then refreshes lifecycle fields, new messages, and pending tools until you call close(), abort the supplied signal, or the conversation completes. Pass clientTools, deploymentId, pollIntervalMs, messagePageSize, or signal as options.
Subscribe
subscribe() receives every state update produced by start() or open(). It does not create another connection.
const unsubscribe = client.agent.subscribe((state) => {
console.log(state.runStatus, state.pendingToolCalls)
})
await client.agent.open(process.env.AMARSIA_CONVERSATION_ID!)
// Later, when this consumer is removed:
unsubscribe()
client.agent.close()React applications normally use useAgent(client) instead; it subscribes and unsubscribes through React's external-store lifecycle.
Load older messages
open() intentionally avoids downloading the complete conversation before rendering. Fetch older pages when your UI reaches the start of its current history:
if (client.agent.historyPageInfo?.hasMore) {
await client.agent.loadMoreHistory({ pageSize: 25 })
}The SDK merges history by stable event ID and exposes the opaque nextCursor and hasMore through historyPageInfo. messages remains available as a message-only view derived from history.
Continue
Use continue() after reopening an interrupted run or when your UI deliberately starts another turn.
await client.agent.open(process.env.AMARSIA_CONVERSATION_ID!)
if (client.agent.runStatus === "interrupted") {
await client.agent.continue()
}
client.agent.close()continue() sends [{ type: "text", text: "Continue." }] using the currently open conversation and connected tool handlers.
An interrupted continuation can already have accepted tool results. Use continue() to begin a new turn; do not replay or change those results.
Resolve a tool
Use resolveTool(callId, output) when your UI renders the pending tool itself. The SDK waits until every call in the current round has an output, then submits them together.
const call = client.agent.pendingToolCalls[0]
if (call?.name === "ask_user") {
await client.agent.resolveTool(call.callId, {
answers: [{ id: "goal", value: "Build strength" }],
})
}A configured handler resolves automatically. An unhandled call remains in pendingToolCalls.
State
client.agent and useAgent(client) expose the same state.
| Field | Meaning |
|---|---|
conversationId | Stable conversation identifier |
conversationStatus | active or permanently completed |
completedAt | Completion timestamp, otherwise null |
runId | Latest run identifier for observability; clients do not need to persist or submit it |
runStatus | running, waiting_for_client_action, completed, interrupted, failed, expired, or null |
messages | Actual persisted user and assistant messages |
pendingToolCalls | Normalized { callId, name, arguments } calls |
progress | Safe run and client-action summaries |
toolSummary | Tool names and completion states |
history | Chronological messages, tools, actions, client tools, and completion events |
historyPageInfo | Cursor state for loading older agent activity |
status | Transient SDK controller state used for loading/error UI; it is not the backend run or conversation lifecycle |
error | Normalized SDK error data |
Tool calls, tool results, and server actions are not added to messages.
Examples
Autonomous backend start
This complete Node.js example starts an agent with no connected client tools:
import { amarsia } from "@amarsia/sdk"
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
})
const state = await client.agent.start({
content: [{ type: "text", text: "Analyze today's support escalations." }],
variables: { TEAM: "enterprise" },
meta: { jobId: "job_8421" },
})
console.log(JSON.stringify({
conversationId: state.conversationId,
status: state.conversationStatus,
messages: state.messages,
}, null, 2))
client.agent.close()If the assistant has an always client tool that it chooses to call, the state can remain awaiting_input until a capable client opens the conversation.
Handle ask_user
Configure ask_user on the assistant, then provide a handler:
import { amarsia } from "@amarsia/sdk"
type Question = {
id: string
input_type: "single_select" | "multi_select" | "text"
options?: string[]
}
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
})
const state = await client.agent.start({
content: [{ type: "text", text: "Ask for my onboarding preferences." }],
clientTools: {
ask_user: async ({ questions }) => ({
answers: (questions as Question[]).map((question) => ({
id: question.id,
value:
question.input_type === "multi_select"
? [question.options?.[0] ?? "Other"]
: question.options?.[0] ?? "My answer",
})),
}),
},
})
console.log(state.messages)
client.agent.close()Handle upload_document
Upload the file to your storage first, then return supported message content:
import { amarsia } from "@amarsia/sdk"
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
})
const state = await client.agent.start({
content: [{ type: "text", text: "Collect my signed agreement." }],
clientTools: {
upload_document: async () => ({
content: [{
type: "url",
mime_type: "application/pdf",
file_uri: process.env.UPLOADED_DOCUMENT_URL!,
}],
}),
},
})
console.log(state.conversationId)
client.agent.close()Connect a custom tool
Configure show_plan_picker with a description and when_connected availability in the dashboard. The handler can contribute its input schema:
import { amarsia } from "@amarsia/sdk"
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
})
const showPlanPicker = Object.assign(
async ({ plans }: Record<string, unknown>) => ({
selectedPlan: Array.isArray(plans) ? plans[0] : null,
}),
{
inputSchema: {
type: "object",
properties: {
plans: { type: "array", items: { type: "string" } },
},
required: ["plans"],
},
},
)
await client.agent.start({
content: [{ type: "text", text: "Help me choose a plan." }],
clientTools: { show_plan_picker: showPlanPicker },
})
client.agent.close()The tool is not exposed when no matching connected capability exists. Choose always instead only when waiting for a later capable client is intentional.
Email an always document flow
Configure upload_document as always, create a hosted-agent trigger, and put the printed URL into your email provider:
import { amarsia } from "@amarsia/sdk"
const deploymentId = process.env.AMARSIA_DEPLOYMENT_ID!
const client = amarsia.init({
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId,
})
const trigger = await client.trigger.create({
content: [{ type: "text", text: "Collect the signed service agreement." }],
variables: { CUSTOMER_ID: "cus_123" },
uiSurface: "agent",
ttlHours: 72,
})
const link = `https://chat.amarsia.com/agent/${deploymentId}?tid=${trigger.id}`
console.log(link)The hosted route starts the trigger and replaces the URL with cid. That conversation URL can then survive refresh and reopening on another device.
Reconnect from React
This browser example uses an origin-allowed workflow and renders a single text ask_user question:
"use client"
import { useAgent } from "@amarsia/react"
import { amarsia } from "@amarsia/sdk"
import { useEffect, useState } from "react"
const client = amarsia.init({
deploymentId: process.env.NEXT_PUBLIC_AMARSIA_DEPLOYMENT_ID!,
})
export function AgentQuestion({ conversationId }: { conversationId: string }) {
const { agent, messages, pendingToolCalls, status } = useAgent(client)
const [answer, setAnswer] = useState("")
const call = pendingToolCalls.find((item) => item.name === "ask_user")
useEffect(() => {
void agent.open(conversationId)
return () => agent.close()
}, [agent, conversationId])
if (call) {
const questions = call.arguments.questions as Array<{
id: string
input_type: "single_select" | "multi_select" | "text"
}>
const question = questions[0]
if (questions.length !== 1 || question?.input_type !== "text") {
return <p>This example expects one text question.</p>
}
return (
<form onSubmit={(event) => {
event.preventDefault()
void agent.resolveTool(call.callId, {
answers: [{ id: question.id, value: answer }],
})
}}>
<input value={answer} onChange={(event) => setAnswer(event.target.value)} />
<button type="submit">Continue</button>
</form>
)
}
return <p>{status}: {messages.length} messages</p>
}Persist conversationId in your route or application database. The hook subscribes to the same controller state as the SDK.
Open in multiple clients
Two clients can safely open the same conversation. The SDK refreshes current state when submissions race:
import { amarsia } from "@amarsia/sdk"
const config = {
apiKey: process.env.AMARSIA_API_KEY!,
deploymentId: process.env.AMARSIA_DEPLOYMENT_ID!,
}
const conversationId = process.env.AMARSIA_CONVERSATION_ID!
const first = amarsia.init(config)
const second = amarsia.init(config)
await Promise.all([
first.agent.open(conversationId),
second.agent.open(conversationId),
])
const firstCall = first.agent.pendingToolCalls[0]
const secondCall = second.agent.pendingToolCalls[0]
if (firstCall && secondCall && firstCall.callId === secondCall.callId) {
await Promise.allSettled([
first.agent.resolveTool(firstCall.callId, {
answers: [{ id: "approval", value: "Approve" }],
}),
second.agent.resolveTool(secondCall.callId, {
answers: [{ id: "approval", value: "Approve" }],
}),
])
}
console.log(first.agent.getState(), second.agent.getState())
first.agent.close()
second.agent.close()Identical retries are idempotent. If different outputs race, the first accepted output remains authoritative and the other client refreshes.
Complete a conversation
Enable Allow conversation completion on the assistant. The model can then permanently complete the v2 conversation:
await client.agent.open(process.env.AMARSIA_CONVERSATION_ID!)
const state = await client.agent.continue({
content: [{ type: "text", text: "Everything is resolved. Close this case." }],
})
if (state.conversationStatus === "completed") {
console.log("Read-only since", state.completedAt)
console.log(state.messages)
}
client.agent.close()Your code observes completion; it does not call complete_conversation directly. continue() rejects an already completed conversation.
Migrate safely
No migration is required for existing v1 customers. The v1 endpoints, client.conversation, useConversation, and message items remain compatible; lifecycle fields on message reads are additive.
| Existing integration | Recommended change |
|---|---|
| Ordinary v1 chat or streaming | Keep it unchanged |
Legacy conversation.run({ clientTools }) loop | Move customer-facing tools to client.agent.start() |
Saved v1 conversation_id | Continue using it with v1, or open an active compatible conversation with client.agent.open() |
| Custom refresh and resume code | Replace it with agent.open(), handlers, and resolveTool() |
| Agent activity UI | Render the ordered history; use messages only when a message-only surface is intended |
All v2 conversations are durable. Persist only conversationId; the SDK and backend recover the current run and pending calls from the conversation.