npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zoowork-ai/sdk

v0.8.0

Published

TypeScript SDK for the ZooWork Managed Agents API (Developer Preview)

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/sdk

Quickstart

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 an agt_…. Resolve it with zc.listAgents({ labels: { workspace_id: '<32-hex>' } }); use the pagination patterns below to read one page or traverse every match. Scope is owner_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 on status.actual_state. actual_state reports chat-channel connectivity. An API-only agent has no channels, but its projection depends on the channel-status capability: a GET can report active with zero channel counts and a status_message saying health was not verified when that capability is unsupported, while a transient health lookup failure remains activating. List and GET can therefore briefly disagree. None of these values is readiness, and running is not an actual_state value. Use await zc.waitUntilRunning(agentId); it correctly polls desired_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.cursor and resume with { cursor }. Do not derive it from seq or mix it with after: after selects the deprecated event lane, which omits user-input events. The SDK sends the cursor in the query; do not rely on a raw Last-Event-ID header 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 read eventType. 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. stopAgent and deleteAgent leave them running; list and delete them yourself. Also available: getSchedule, updateSchedule, triggerSchedule, listScheduleRuns.
  • updateSchedule must omit sessionTarget. It is immutable, and echoing it back from a getSchedule result — the obvious thing to do — is a 400. The types refuse it for you.
  • An interval uses { kind: 'every', everyMs: 60_000 }. Optional anchorMs aligns it. The earlier every type was incorrect; migrate explicitly, without guessing string units. This correction and optional ScheduleRun.session_id are source-reviewed. Use that session link only when present; it is not a run-success indicator.
  • exec resolves on a failed command. A non-zero exit is still HTTP 200: check exit_code, don't wait for a rejection. It runs in /workspace and needs an agent-scope sandbox.
  • A cron job can carry an outcome gate. payload.outcome says what "done" looks like — a sandbox command whose exit 0 means satisfied, or an LLM rubric graded in a fresh context. The run evaluates and revises itself up to maxIterations (1–5), and under the default publish: 'after_satisfied' a result that failed evaluation is not announced. An agent-level default lives at resource.outcome; a job's own outcome overrides it, and an explicit null opts 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 cap

Each 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-lockfile

Run staging E2E explicitly when you want to verify the SDK (Node 22.20+):

pnpm test:e2e

The 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