@polpo-ai/sdk
v0.15.110
Published
Polpo SDK — typed HTTP client, SSE streaming, and reactive store for AI agent orchestration
Maintainers
Readme
@polpo-ai/sdk
Typed HTTP, SSE, and reactive-store client for Polpo.
import {
PolpoClient,
PolpoStore,
isRuntimePlanSSEEvent,
} from "@polpo-ai/sdk";
const client = new PolpoClient({ baseUrl: "http://localhost:3890" });
await client.chatCompletions({
agent: "support",
messages: [{ role: "user", content: "Check this order" }],
model: "openai/gpt-5",
sandbox: {
isolation: "fresh",
volumes: [{ name: "reference", access: "read-only" }],
lifecycle: {
onRelease: "pool",
stopAfterIdleMinutes: 30,
deleteAfterStopMinutes: 60,
},
},
guardrails: { policyPack: "strict" },
});
const store = new PolpoStore();
// Pass SSE events to store.applyEvent(event).
const latestPlan = store.getSnapshot().latestRuntimePlanId;With sandbox.isolation: "fresh", Polpo acquires one clean sandbox for the
outer completion. Deterministic tools and nested Agentic Loop steps share its
filesystem until that completion finishes. Allocation and release are
independent: lifecycle.onRelease either returns the sandbox to the
project-scoped pool or destroys it. Pooled sandboxes can be stopped after
stopAfterIdleMinutes and deleted after another deleteAfterStopMinutes.
The deprecated idleTtlMinutes field remains accepted on its own for older
clients, but cannot be mixed with the explicit controls.
Use sandbox.isolation: "shared" only when concurrent outer runs are intended
to collaborate in one project-scoped workspace. reuse remains exclusive and
only makes the sandbox available to another run after release.
sandbox.volumes selects host-defined persistent volumes by name. The request
may remove an agent grant or narrow it to read-only or manual writeback, but
cannot add an ungranted volume, change its strategy, or choose a host mount
path. The only provider-neutral strategies are mounted and hydrated;
strategy and storage credentials are resolved by the runtime host.
Hosts with managed volume APIs can expose their catalog and grants through the typed SDK methods:
const { volumes } = await client.listSandboxVolumes();
const volume = await client.createSandboxVolume({
name: "build_cache",
strategy: "hydrated",
access: "read-write",
writeBack: "auto",
});
await client.setSandboxVolumeGrant("builder", volume.id, {
access: "read-write",
writeBack: "manual",
});
await client.updateSandboxVolume("build_cache", { label: "Build cache" });
await client.revokeSandboxVolumeGrant("builder", volume.id);
await client.deleteSandboxVolume("build_cache");Grant methods intentionally accept the immutable volume id returned by the catalog. Runtime requests continue to select volumes by stable name. Provider credentials and storage locations never enter these payloads.
runtime:plan SSE payloads can be narrowed with
isRuntimePlanSSEEvent. The store indexes valid, secret-free plans by id and
retains malformed raw events only in its bounded diagnostics history.
Runtime context accounting types are exported from the SDK and originate from
@polpo-ai/core/runtime-inspection, so managed and self-hosted inspectors use
the same categories.
Continue after an SSE disconnect
Durable delivery is opt-in. Existing requests retain cancel-on-disconnect
behavior. Set polpo.delivery.onDisconnect to continue when execution must
outlive the current SSE subscriber:
const stream = client.chatCompletionsStream({
agent: "builder",
messages: [{ role: "user", content: "Build and test the application" }],
polpo: { delivery: { onDisconnect: "continue" } },
});
stream.subscribeConnectionState((state) => {
// streaming | reconnecting | closed
console.log(state);
});
for await (const chunk of stream) {
console.log(chunk.choices[0]?.delta.content ?? "");
}The initial response includes x-polpo-run-id; each persisted frame has an SSE
id. The SDK reconnects to GET /v1/runs/{runId}/events from the last complete
cursor and never repeats the creation request. Invalid and ahead cursors fail
explicitly; hosts with bounded event retention also reject expired cursors.
Use the stream controls deliberately:
stream.detach()closes only this subscriber; a durable run continues;await stream.cancel(reason)requests idempotent server-side cancellation;stream.abort()keeps its historical cancel meaning;stream.resume({ after })explicitly reattaches an existing stream.
Canonical events are also available without the chat projection:
for await (const event of client.streamRunEvents(runId, { after: lastEventId })) {
console.log(event.sequence, event.type, event.data);
}Self-hosted SQLite and PostgreSQL persist replay events and cancellation state. The file-storage fallback keeps them only in process memory, so it survives a subscriber disconnect but not a server restart.
Activate skills per request
The management client can synchronize complete binary-safe skill bundles,
including references/, scripts/, and assets/:
const bundle = await client.getSkillBundle("frontend-design");
await client.putSkillBundle(bundle);For project-local installation and assignment, use polpo skills add and
polpo deploy; these preserve the same complete bundle contract.
The runtime consumes that contract directly: skill_read({ name }) returns
the skill entrypoint together with its textual references/ resources, while
skill_read({ name, path }) reads one exact bundle-relative resource. Clients
do not need to rewrite imported skills or instruct the model to use workspace
file tools for bundle content. Assigned skills implicitly authorize
skill_list and skill_read through static agent and Loop allowlists;
request, route, execution, and trusted-grant restrictions remain
authoritative.
An agent can have several assigned skills while a caller explicitly applies one or more of them to a single execution:
const response = await client.chatCompletions({
agent: "builder",
messages: [{ role: "user", content: "Build the settings page." }],
polpo: {
skills: ["frontend-design"],
},
});This is additive and ephemeral. The selected skill is prioritized for that
request, other skills assigned to the agent remain available, and the agent
configuration is not changed. Polpo rejects skills that are not assigned to
the effective agent or loop. Slash commands are a client-side convenience:
clients should translate /frontend-design into polpo.skills rather than
expecting the server to parse message text.
With the React SDK, pass the selection to the individual message:
await chat.sendMessage("Build the settings page.", {
skills: ["frontend-design"],
});Restrict the tools exposed for one execution with
polpo.execution.allowedTools. This is an intersection with agent, mode, Loop,
Route, and trusted grant policy; it can never expand configured access:
const response = await client.chatCompletions({
agent: "leo",
messages: [{ role: "user", content: "Configure the site connector." }],
tools: [configureSiteConnector],
polpo: {
execution: {
allowedTools: ["ask_user_question", "configure_site_connector"],
},
},
});The same restriction can be supplied to continueClientToolResult through its
allowedTools option. It is retained when the continuation explicitly starts a
Project Loop, while chat or Channel Route policy is recalculated for Loop mode.
Structured outputs
Use the OpenAI-compatible response_format field when the final assistant
message must contain validated JSON:
const response = await client.chatCompletions({
agent: "support",
messages: [{ role: "user", content: "Classify this customer." }],
response_format: {
type: "json_schema",
json_schema: {
name: "customer_tier",
strict: true,
schema: {
type: "object",
properties: {
tier: { type: "string", enum: ["free", "paid"] },
},
required: ["tier"],
additionalProperties: false,
},
},
},
});
const result = JSON.parse(response.choices[0].message.content ?? "{}");The same request works with chatCompletionsStream. Polpo buffers the
structured value until it is complete and schema-valid, then emits one
canonical JSON content chunk. Existing text requests are unchanged.
Parallel server tool calls
Opt in when an agent can satisfy one turn with independent server-side reads:
const response = await client.chatCompletions({
agent: "researcher",
messages: [{ role: "user", content: "Check both data sources." }],
parallel_tool_calls: true,
});Polpo uses bounded concurrency only when every call in the batch is classified
read-only. Batches containing a write or an unknown tool remain sequential.
Results and persisted history retain call order even when individual tools
finish out of order. false and omitted requests remain sequential.
This option applies to server-executed tools. Request-scoped client tools must
set it to false.
Client-side tools
Declare OpenAI-compatible tools on an individual direct-chat request when the calling application, rather than Polpo, owns the action:
const response = await client.chatCompletions({
agent: "leo",
messages: [{ role: "user", content: "Configure commerce" }],
tools: [{
type: "function",
function: {
name: "configure_site_module",
description: "Open the module configuration UI.",
parameters: {
type: "object",
properties: { module: { type: "string" } },
required: ["module"],
additionalProperties: false,
},
strict: true,
},
}],
tool_choice: "auto",
parallel_tool_calls: false,
});
if (response.choices[0]?.finish_reason === "tool_calls") {
const call = response.choices[0].message.tool_calls?.[0];
// Execute call.function in the client, then continue the same session with
// the assistant tool-call message and a role=tool result message.
}Polpo never invokes request-scoped tools on the server. A client-side call is returned atomically; mixed or parallel calls fail closed. Project Loops do not accept request-scoped client tools.
To continue after a client result, start the direct request as a stream and retain its tool call plus response metadata:
const direct = client.chatCompletionsStream({
agent: "leo",
messages: [{ role: "user", content: "Create a booking site" }],
tools: [{
type: "function",
function: {
name: "configure_site_module",
parameters: { type: "object", properties: {}, additionalProperties: false },
},
}],
parallel_tool_calls: false,
});
let toolCallId = "";
for await (const chunk of direct) {
toolCallId = chunk.choices[0]?.delta.tool_calls?.[0]?.id ?? toolCallId;
}
const nextTurn = client.continueWithToolResult({
sessionId: direct.sessionId!,
sessionVersion: direct.sessionVersion!,
idempotencyKey: crypto.randomUUID(), // retain this value for retries
agent: "leo",
toolCallId,
result: JSON.stringify({ cancelled: true }),
});
for await (const chunk of nextTurn) {
console.log(chunk.choices[0]?.delta.content ?? "");
}The continuation sends exactly one OpenAI-compatible role: "tool" message.
Polpo validates it against the latest pending call, rebuilds history from the
session store, and continues direct chat. Add loop: "build-site" to the same
request to hand off into a durable Project Loop instead. Retry the same request
with the same idempotency key; a changed payload, stale version, wrong
user/scope, or an already-resolved call fails deterministically. The raw API requires
x-session-id, Idempotency-Key, stream: true, and
polpo.delivery.onDisconnect: "continue".
Do not send an isolated role: "tool" message. Without its matching assistant
tool call in the request history or polpo.continuation, Polpo rejects it with
client_tool_continuation_required before model or Loop execution. Use
continueWithToolResult() so session versioning, idempotency, canonical history
reconstruction, and durable delivery are applied together.
Chat interactions
Enable only interactions your client can render:
const response = await client.chatCompletions({
agent: "support",
messages: [{ role: "user", content: "Help me configure this" }],
polpo: {
capabilities: {
ask_user_question: true,
suggestions: true,
},
},
});
for (const suggestion of response.polpo?.suggestions ?? []) {
console.log(suggestion.label, suggestion.prompt);
}For streaming requests, ChatCompletionStream.suggestions contains the latest
validated suggestions after the stream completes. Each item has only id,
label, and the exact prompt to send as the next user message. The React
useChat hook requests both supported interactions, exposes suggestions, and
stores them on the assistant message that produced them. When resuming a
session, useChat restores active suggestions only when that assistant message
is still the latest message; historical suggestions remain attached to their
original messages without being offered again after a newer user turn.
Steer an active run
Start a streaming chat request before iterating it to read the active run id from the response headers, then send steering through the authenticated API:
const stream = client.chatCompletionsStream({
agent: "builder",
messages: [{ role: "user", content: "Create the dashboard" }],
});
await stream.start();
if (!stream.runId) throw new Error("This execution does not support steering");
await client.steerRun(stream.runId, {
id: crypto.randomUUID(),
mode: "steer",
content: {
text: "Use the attached reference for the next revision.",
attachments: [{
type: "image",
url: "https://example.com/reference.png",
mediaType: "image/png",
}],
},
});
for await (const chunk of stream) {
// Consume the response normally.
}Use mode: "follow_up" to run the message only after the current work would
otherwise stop. Use client.abortRun(stream.runId, "Cancelled by user") for the
existing steering abort endpoint. On a durable completion, stream.cancel() is
the acknowledged cancellation API and stream.detach() is the local-only
transport operation. Steering is available on Run-backed execution and never
interrupts an individual tool call in progress.
