@zoowork-ai/sdk
v0.8.0
Published
TypeScript SDK for the ZooWork Managed Agents API (Developer Preview)
Maintainers
Readme
@zoowork-ai/sdk
TypeScript SDK for the ZooWork Managed Agents API. Developer Preview.
Zero runtime dependencies — it uses the platform fetch, which you can override for edge runtimes and tests. ESM only, Node 20+.
npm install @zoowork-ai/sdkQuickstart
You need an API key (zct_...) issued for your organization — create one in the ZooWork App under Settings → API Keys (any personal org; enterprise orgs need the admin role), or ask your org admin for one. The secret is shown exactly once at creation. Keep it server-side: it authenticates as your whole organization, not as one end user.
import { createZooworkClient } from '@zoowork-ai/sdk'
const zc = createZooworkClient({ apiKey: process.env.ZOOWORK_API_KEY })
// Or set ZOOWORK_API_KEY and pass nothing at all:
// const zc = createZooworkClient()The base URL has a working default, so you do not configure an endpoint. Override it with
ZOOWORK_BASE_URL, or with baseUrl on the call, to point at a different deployment.
// 1. Create an agent. Ownership is derived from your key, so `resource` is all you
// send. Select a model returned by this deployment instead of relying on a
// remembered id or on a server default that can rotate.
const models = await zc.listModels()
const primary = models.find(
(model) => model.selectable !== false && model.model === 'litellm/gpt-5.6-terra',
)?.model
if (!primary) throw new Error('Choose a selectable model returned by listModels()')
const agent = await zc.createAgent({
resource: { name: 'research-agent', model: { primary } },
})
// 2. Start it. Without this, createSession() returns 409 agent_not_running.
await zc.startAgent(agent.agent_id)
// 3. Open a session with the first message already in it.
const session = await zc.createSession(agent.agent_id, {
initial_events: [{ type: 'user.message', content: 'What can you do?' }],
})listModels() can include a model whose retirement has started. Check selectable !== false
before using a catalog row in a new Agent or config. A non-selectable choice returns
409 model_not_selectable; expired_fallback_to names the reviewed replacement when present.
Agent resources also accept userTimezone, a named IANA timezone used for prompt and message
time context, and include_global_skills: false to disable automatic global Skills while keeping
explicitly listed Skills. An explicit skills: [] also opts out. Schedule timezones are configured
separately.
Configuration
| Option | Environment variable | Default |
|---|---|---|
| apiKey | ZOOWORK_API_KEY | none - construction throws without one |
| baseUrl | ZOOWORK_BASE_URL | the public gateway (DEFAULT_BASE_URL) |
| fetch | - | globalThis.fetch |
An explicit option always beats the environment variable.
Finding the agent you built in the app. The first path segment of a ZooWork chat URL (
/chat/<32-hex>/sessions/…) is a workspace id, not anagt_…. Resolve it withzc.listAgents({ labels: { workspace_id: '<32-hex>' } }); use the pagination patterns below to read one page or traverse every match. Scope isowner_uid AND org_id— an agent a colleague created in your org is fetchable by id but will not appear in your list.
Wait on
status.desired_state, never onstatus.actual_state.actual_statereports chat-channel connectivity. An API-only agent has no channels, but its projection depends on the channel-status capability: a GET can reportactivewith zero channel counts and astatus_messagesaying health was not verified when that capability is unsupported, while a transient health lookup failure remainsactivating. List and GET can therefore briefly disagree. None of these values is readiness, andrunningis not anactual_statevalue. Useawait zc.waitUntilRunning(agentId); it correctly pollsdesired_state.
Listing agents and pagination
listAgents() returns an awaitable, async-iterable request. Use for await to traverse all
matching agents; the SDK requests each next page only as you consume the results:
for await (const agent of zc.listAgents({ labels: { project: 'research' } })) {
console.log(agent.agent_id)
// break when you have enough; later pages will not be fetched.
}For a single page, await the request and read .data. The page preserves page,
page_size, and total, and exposes next_page (null when there are no more results):
const page = await zc.listAgents()
console.log(page.data, page.total, page.next_page)
if (page.hasNextPage()) {
const next = await page.getNextPage() // retains the original label filters
console.log(next.data)
}You can also use for await (const agent of page) to iterate from an already-fetched page,
or for await (const batch of page.iterPages()) to process one page at a time.
getNextPage() rejects if there is no next page; errors fetching later pages reject iteration.
The API uses numeric pages starting at 1, with a fixed page size of 100. The SDK derives
next_page from the returned page, page_size, and total; it is a number, not an opaque
cursor. To resume explicitly, use zc.listAgents({ page: nextPage, labels: originalLabels }).
There is no configurable limit. Pagination is not a snapshot: concurrent additions or
deletions can shift results between pages.
Migration from the array return: replace const agents = await zc.listAgents(opts) with
const { data: agents } = await zc.listAgents(opts) to keep reading one page, or switch to
for await to read every match. Missing or invalid pagination metadata now raises an error
instead of silently returning an empty or apparently complete array.
Streaming a turn
run.finished ends a turn; assistant text arrives on agent.assistant.
import { assistantText, customToolUse, isRunFinished, runOutcome, toolCall } from '@zoowork-ai/sdk'
for await (const ev of zc.streamEvents(agent.agent_id, session.session_id)) {
process.stdout.write(assistantText(ev)) // '' for every non-assistant event
const call = toolCall(ev) // present only on agent.tool; pair start/end by toolCallId
if (call?.phase === 'start') console.log(`\n[tool] ${call.toolName}`)
if (isRunFinished(ev)) {
console.log(`\n-> ${runOutcome(ev)}`) // succeeded | failed | aborted
break
}
}Three things worth knowing before you write that loop:
- The stream is session-scoped and does not close when a turn ends. The server closes it after an idle period. Break on
isRunFinished(ev)yourself, or you block until that timeout. - Save the opaque cursor. After consuming an event, retain
ev.cursorand resume with{ cursor }. Do not derive it fromseqor mix it withafter:afterselects the deprecated event lane, which omits user-input events. The SDK sends the cursor in the query; do not rely on a rawLast-Event-IDheader passing through the public gateway. - The default unified REST and SSE wire formats use snake_case. Older event formats differ; the SDK normalizes both into
SessionEvent, where you readeventType. Keep the cursor unchanged.
For API sessions, user.message can carry actor: { ref: 'customer-42' }, including in
initial_events. This source-reviewed field selects per-user memory attribution. Your server
must authenticate the user and authorize the session; actor.ref does neither. It does not
isolate the shared agent's sandbox files or erase a session's previous context. Omit actor
to use the owner; IM sessions reject it. See the field's SDK comment for input constraints.
Bring your own skill
A skill is a zip. One upload creates the skill and its first version; putAgentSkill attaches it.
import { readFile } from 'node:fs/promises'
const skill = await zc.uploadSkill(await readFile('market-research.zip'), { scope: 'org' })
await zc.putAgentSkill(agent.agent_id, skill.skill_id)The zip's single top-level directory must be named exactly like the name in its SKILL.md
frontmatter — market-research/SKILL.md declaring name: market-research. A mismatch is a 400,
and it is the first one nearly everyone gets. scope is org or personal; the preinstalled
global skills are listable but not installable with an API key, so this is the only way to
control what a skill says. uploadSkillVersion publishes an update, and agents that installed it
unpinned follow along without another putAgentSkill.
uploadSkillVersion returns a SkillVersionRecord with version and state, not the
latest_version and status of the SkillRecord returned by uploadSkill. This return
contract is source-reviewed, not a new live recording. On initial create, put the description
in the zip's frontmatter: the gateway drops the description option. Version uploads can
use that override. A successful create retried under the same name can return 409 skill_exists;
read back first. Version uploads deduplicate identical content for the same skill, not HTTP keys.
Schedules, wake and exec
await zc.createSchedule(agent.agent_id, {
schedule_id: 'daily-report',
schedule: { kind: 'cron', expr: '0 9 * * *', tz: 'Asia/Singapore' },
payload: { kind: 'agentTurn', message: 'Generate the daily report.' },
})
await zc.wake(agent.agent_id, { text: 'Review the pending deployment.' }) // at the next heartbeat
const { exit_code, stdout } = await zc.exec(agent.agent_id, ['bash', '-lc', 'pwd'])- Schedules outlive their agent.
stopAgentanddeleteAgentleave them running; list and delete them yourself. Also available:getSchedule,updateSchedule,triggerSchedule,listScheduleRuns. updateSchedulemust omitsessionTarget. It is immutable, and echoing it back from agetScheduleresult — the obvious thing to do — is a 400. The types refuse it for you.- An interval uses
{ kind: 'every', everyMs: 60_000 }. OptionalanchorMsaligns it. The earliereverytype was incorrect; migrate explicitly, without guessing string units. This correction and optionalScheduleRun.session_idare source-reviewed. Use that session link only when present; it is not a run-success indicator. execresolves on a failed command. A non-zero exit is still HTTP 200: checkexit_code, don't wait for a rejection. It runs in/workspaceand needs an agent-scope sandbox.- A cron job can carry an outcome gate.
payload.outcomesays what "done" looks like — a sandboxcommandwhose exit 0 means satisfied, or an LLMrubricgraded in a fresh context. The run evaluates and revises itself up tomaxIterations(1–5), and under the defaultpublish: 'after_satisfied'a result that failed evaluation is not announced. An agent-level default lives atresource.outcome; a job's ownoutcomeoverrides it, and an explicitnullopts the job out. Cron fires only — heartbeats and interactive sessions never evaluate.
Sessions, approvals, environments
listSessions keeps the legacy numeric-page contract. Use listSessionPage for the filtered
cursor lane: it starts with sls1:0, accepts channel/surface/runtime/archive filters, and returns
next_cursor plus a list_cursor on each row. Cursors are opaque and bound to the same filters.
Pass { includeDeleted: true } to include deletion tombstones for reconciliation; returned rows
then carry deleted and the page carries includes_deleted: true. This flag is part of the cursor
scope, so do not reuse a cursor created without it.
archiveSession and deleteSession round out the session surface. There is no
patchSession: the gateway does not proxy PATCH at all (405), so session metadata is fixed at
creation time.
An application-executed tool is declared in resource.custom_tools. When
customToolUse(ev)?.phase === 'requested', execute the named operation and call
resolveCustomToolCall; listCustomToolCalls recovers pending work after a restart. You may also
post a typed user.custom_tool_result event to the owning session. The run reports
awaiting_approval while paused, so use pending_custom_tool_calls to distinguish this wait from
a normal approval. These contracts are source-reviewed and need deployment verification.
const call = customToolUse(ev)
if (call?.phase === 'requested') {
await zc.resolveCustomToolCall(agentId, call.callId, {
content: [{ type: 'json', value: { price: 42 } }],
resolvedBy: 'pricing-service',
})
}listApprovals / resolveApproval expose the approvals resource — decision is one of
allow-once, allow-always, deny. End-to-end approval and turn-budget behavior need
separate verification on the deployment you use.
Approval response fields are source-reviewed, not end-to-end verified: read requested_at,
allowed_decisions and optional timeout/resolution fields defensively. signaled: true means
the resolution was accepted; a returned status: 'pending' is not completed execution.
listEnvironments, getEnvironment, createEnvironment, createEnvironmentVersion,
getEnvironmentVersion and archiveEnvironment manage prebuilt sandbox images (apt/npm/pip
packages, files, a build script, and an outbound allowlist). Two facts worth having before you
start: an agent's Environment freezes on its first sandbox creation — after that every change
is 409 environment_locked, and stopping the agent does not clear it — and sandbox networking
defaults to unrestricted unless the Environment declares networking: { type: 'limited' }.
Build polling must have a deadline and handle partial_ready: some resource classes can be
ready while others are building or failed. getEnvironmentVersion(id, version, { resourceClass:
'starter' }) selects one class; omitting the option keeps the aggregate read. Re-read the
aggregate before concluding the build is fully ready. These details are source-reviewed.
Channel callers must not use allow_from as an access-control list: the public gateway ignores
it. Use supported dm_policy settings.
Artifacts and the system prompt
const { artifacts, has_more } = await zc.listArtifacts(agent.agent_id)
const { url } = await zc.downloadArtifact(agent.agent_id, artifacts[0].artifact_id)
const { declaration, effective } = await zc.getSystemPrompt(agent.agent_id)Artifacts are published by the agent's own artifact_publish tool during a turn — there is no
API for publishing from outside the loop. This surface lists what the agent published,
re-resolves an access URL (downloadArtifact mints a fresh one; the URL is a revocable bearer
capability, so treat it like a secret), and deletes. These routes demand owner_uid/org_id
selectors; the SDK derives both from the agent's own projection and caches them, at the cost of
one extra GET on first use.
getSystemPrompt answers the pinned template version and the rendered result;
previewSystemPrompt assembles the exact prompt for a given set of runtime facts without
touching any session. The pin is set at create time and never follows a later platform
activation on its own — moving it is one explicit call, upgradeSystemPrompt, which takes
the agent's current config_version as a CAS (409 config_version_changed on a stale one)
and answers the new pin plus the version bump it cost.
Two helpers
const agent = await zc.waitUntilRunning(agentId) // polls desired_state, not actual_state
const events = await zc.listAllEvents(agentId, sessionId) // pages past the silent 500 capEach wraps a trap that is invisible from the outside: readiness lives in status.desired_state,
and listEvents truncates at 500 events with nothing in the response to say it did.
Documentation
Full guides and the capability matrix: zoowork-agents-docs.
Runnable examples in examples/:
live-smoke.ts— drive one agent through one turn and verify the REST and SSE reads agree.capability-probe.ts— create a throwaway agent, walk the whole lifecycle, and print a verdict per capability.
Testing and publishing (maintainers)
Testing and publishing are independent commands. After cloning this repository on a new machine, install its locked development dependencies:
pnpm install --frozen-lockfileRun staging E2E explicitly when you want to verify the SDK (Node 22.20+):
pnpm test:e2eThe command prepares an isolated test package, prints individual offline cases and timed live
steps, and asks for a staging API key with input hidden. Submitting the key authorizes one
temporary Agent/Session, one potentially billable model turn and cleanup. JSON reports remain
in its printed private directory. The test never publishes. Normal pnpm test is offline
and needs no key. See E2E and recovery instructions for scope and options.
Publishing runs through .github/workflows/release.yml. Configure
the npm package's Trusted Publisher once with organization SerendipityOneInc, repository
zoowork-sdk-typescript, workflow release.yml, and direct publish permission. No npm token or
repeated npm login is needed after that.
For each release, merge the intended version and changelog, then publish a GitHub Release whose
tag is exactly v<package version> — for example, v0.7.0. The workflow verifies that match,
runs the offline test and build gates, and publishes the public package with npm OIDC. A mismatched
tag fails before publication, and an existing npm version cannot be overwritten.
The release workflow does not run live E2E or read a staging key. Run pnpm test:e2e separately
before creating the GitHub Release when live verification is required. Use
npm publish --dry-run locally to inspect the package without uploading it.
License
MIT
