Amarsia
Client Usage

Agent SDK and React

Build durable agent flows with start, reconnect, Continue, client tools, completion state, and React hooks.

Installation

npm install @amarsia/sdk

Use 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.

OptionTypeRequiredDescription
contentMessageContent[]Unless triggerId supplies itNon-empty user content
deploymentIdstringNoOverrides the deployment configured on the client
conversationIdstringNoActive conversation to continue
triggerIdstringNoPrepared trigger for the initial turn
variablesobjectNoInitial prompt variables
metaobjectNoInitial searchable metadata
historyLimitnumberNoPrior message pairs supplied to the model
clientToolsAgentClientToolsNoConnected handlers and optional schemas
pollIntervalMsnumberNoInternal refresh interval from 500–60,000 ms; default 2,000
messagePageSizenumberNoInitial and refresh message page size from 1–100; default 100
signalAbortSignalNoAborts 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.

FieldMeaning
conversationIdStable conversation identifier
conversationStatusactive or permanently completed
completedAtCompletion timestamp, otherwise null
runIdLatest run identifier for observability; clients do not need to persist or submit it
runStatusrunning, waiting_for_client_action, completed, interrupted, failed, expired, or null
messagesActual persisted user and assistant messages
pendingToolCallsNormalized { callId, name, arguments } calls
progressSafe run and client-action summaries
toolSummaryTool names and completion states
historyChronological messages, tools, actions, client tools, and completion events
historyPageInfoCursor state for loading older agent activity
statusTransient SDK controller state used for loading/error UI; it is not the backend run or conversation lifecycle
errorNormalized 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 integrationRecommended change
Ordinary v1 chat or streamingKeep it unchanged
Legacy conversation.run({ clientTools }) loopMove customer-facing tools to client.agent.start()
Saved v1 conversation_idContinue using it with v1, or open an active compatible conversation with client.agent.open()
Custom refresh and resume codeReplace it with agent.open(), handlers, and resolveTool()
Agent activity UIRender 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.