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

@pouchy_ai/admin-sdk

v0.34.0

Published

Typed TypeScript client for the Pouchy Admin API — manage agents, keys, end users, knowledge, skills, channels, schedules, webhooks and credentials headlessly, with a project Admin key.

Readme

@pouchy_ai/admin-sdk

Typed TypeScript client for the Pouchy Admin API — everything the dashboard does, headless. Manage agents, secret keys, end users, knowledge, skills, channels, schedules, webhooks and credentials; read usage, billing, traces and audit logs. Zero runtime dependencies (uses the global fetch).

The Admin API is authenticated with a project-scoped Admin key (pchy_admin_…, from the dashboard Admin Keys page). The project is implied by the key — you never pass a project id.

Install

npm i @pouchy_ai/admin-sdk

Quickstart

import { createAdminClient } from '@pouchy_ai/admin-sdk';

const admin = createAdminClient({ adminKey: process.env.POUCHY_ADMIN_KEY! });

// List agents
const { agents } = await admin.listAgents();

// Create + publish an agent
const { agent } = await admin.createAgent({
  name: 'Support Bot',
  archetype: 'support',
  systemPrompt: 'You are a concise, friendly support agent.'
});
await admin.updateAgent(agent.agentId, { status: 'published' });

// Mint a secret key for your backend to open end-user sessions with
const { key } = await admin.createKey({ label: 'prod-backend', env: 'live' });
console.log(key); // the plaintext token — shown ONCE

// Read this month's usage — MAU, tokens, and the credit meter (all typed)
const { usage } = await admin.getUsage();
console.log(usage.mau, '/', usage.mauLimit, 'MAU');
console.log(usage.credits, '/', usage.creditLimit, 'credits');
// Voice is the priciest metered unit — watch its slice, not just the total
console.log(usage.creditsVoice, 'of those credits were', usage.voiceCalls, 'voice calls');

// Equip an agent with ANY skill — including a docs-only skill.md that has no
// `tools:` block — entirely via the API:
const { skill } = await admin.installSkill({
  md: '---\nname: echo-probe\nallowed_domains:\n  - postman-echo.com\n---\nGET https://postman-echo.com/get echoes the request.'
});
await admin.updateAgent(agent.agentId, { skills: [skill.slug] }); // attach to the agent
const armed = await admin.grantSkill(skill.slug, {
  freeHttp: true,
  grantedDomains: ['postman-echo.com'] // unioned with the manifest's allowed_domains
});
console.log(`armed — ${armed.reprovisioned} running instance(s) updated`);
// The agent can now drive the API from the skill's prose via http_request.

Structured JSON — use extractJson, not a companion agent

const { data } = await admin.extractJson<{ nickname: string | null }>({
  content: '叫我 Alex',
  schema: {
    type: 'object',
    additionalProperties: false,
    required: ['nickname'],                 // strict mode: EVERY property, always
    properties: { nickname: { type: ['string', 'null'] } }  // optional → union with null
  }
});
console.log(data.nickname); // 'Alex'

A companion turn is a conversation engine — persona prompt, memory recall, tools — and when the model returns an empty completion it answers with a natural-language fallback line rather than an empty bubble. That is right for a chat surface and wrong for a parser, and an extraction prompt reliably reaches it, because the reasoning-effort bump off the cheapest tier is gated on conversational/functional cues an extraction input never matches. So a companion agent asked for JSON returns chat filler, fairly consistently.

extractJson shares none of that: no persona, no memory, no tools, no session, no fallback — one provider call with response_format set. strict defaults to true, which makes the provider enforce the schema; the schema must then sit inside the provider's structured-output subset (root object, additionalProperties: false, every property listed in required). Pass strict: false for schemas outside it — you still get JSON mode plus server-side validation.

reasoningEffort — matters most when one input carries several directives

reasoningEffort ('minimal' | 'low' | 'medium' | 'high', default 'low') is the thinking budget. It is worth a moment because extraction fails at the cheap tier in a specific, easy-to-miss way: the summary comes out right and the segmentation collapses.

Measured over 26 utterances against an integrator's own extractor, the previous floor ('minimal') agreed on kind for all 22 inputs carrying a SINGLE directive, then diverged on both inputs carrying more than one — 「以后别叫我宝 贝,叫我老板」 labelled by its leading clause (a boundary) rather than its operative one (a nickname), and 「叫我阿凯,别剧透,我不喜欢虐心结局」 merged three directives into two. In each case the item's own text mentioned every clause; only the count and the label were wrong, which is exactly the failure a schema check cannot catch.

Pass 'minimal' for the cheapest, fastest extraction when each input carries at most one directive. Raise it above the default when the split matters more than the latency.

Precedence between YOUR kinds — "a nickname wins over a boundary when the user also says what to call them" — belongs in the schema's description, not here: this endpoint forwards your schema verbatim and holds no opinion about your vocabulary.

const { data } = await admin.extractJson<{ items: { kind: string; summary: string }[] }>({
  content: '以后别叫我宝贝,叫我老板',
  reasoningEffort: 'low',
  schema: {
    type: 'object',
    additionalProperties: false,
    required: ['items'],
    properties: {
      items: {
        type: 'array',
        items: {
          type: 'object',
          additionalProperties: false,
          required: ['kind', 'summary'],
          properties: {
            kind: {
              type: 'string',
              enum: ['nickname', 'boundary', 'preference'],
              // The precedence rule lives HERE, in your vocabulary's own words.
              description:
                'One directive per item — never merge two of different kinds. If the user says what to CALL them (even alongside what not to), the item is `nickname` and the rejected form goes in the summary; `boundary` is only for a prohibition with no replacement.'
            },
            summary: { type: 'string' }
          }
        }
      }
    }
  }
});

Failures are typed rather than prose, so the recoveries are distinguishable: schema_invalid (400 — fix the schema; retrying verbatim cannot help; only reachable on a strict call, since strict: false never puts a schema on the wire), request_invalid (400 — the provider rejected the request itself: an unknown model, a parameter outside the provider's validation, or any rejection of a schema-less strict: false call — for example JSON mode refusing a custom system prompt that never says "json"; fix the request), unavailable (5xx — transient, back off), and invalid_json (502 — a completion arrived but did not parse or satisfy the schema; the error carries raw, the text actually returned). Tokens roll into the project's month usage like any other model call.

Embedding vectors — use embedTexts, not a second vendor key

The vector sibling of extractJson: text → embedding vectors, stateless, no companion side effects. For a backend that ranks its own inventory by cosine (album/asset pick, memory intent-gate exemplars) and wants one billing/key channel instead of holding a separate embedding vendor's key beside its Pouchy keys — retrieval stays yours; Pouchy only turns text into vectors.

const res = await admin.embedTexts({
  input: ['Send me a casual home coffee selfie', '来张咖啡店自拍'] // ≤ 64 per call
});
// res.model      → 'openai-text-embedding-3-small-768'  (STABLE id — persist it)
// res.dimensions → 768                                   (== every vector's length)
// res.data[i]    → { index: i, embedding: number[] }     (index-aligned)

Two rules keep a stored index honest:

  1. Persist res.model next to every stored vector and refuse mixed-model cosine. Then pin it in later requests (model: 'openai-text-embedding-3-small-768'): an id the deployment cannot serve exactly answers 400 — never a silent substitute — so a platform model swap surfaces on your side as an explicit reindex signal.
  2. Texts are never silently truncated. An over-cap text (> 8000 chars) is a 400 naming its index; split or summarize it yourself, so what you embedded is always exactly what you stored.

For bulk reindex, encodingFormat: 'base64' returns each vector as little-endian float32 base64 (~3.5× smaller at 768 dims). Decode with the offset-safe form:

const res = await admin.embedTexts({
  input: ['来张咖啡店自拍'],
  encodingFormat: 'base64'
});
for (const { embedding } of res.data) {
  const buf = Buffer.from(embedding as string, 'base64');
  const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
  // vec.length === res.dimensions, always
  void vec;
}

Not new Float32Array(buf.buffer) — on Node runtimes that pool small buffers (every 768-dim vector qualifies), .buffer is the whole shared pool and the naive form silently decodes ~2048 garbage-tailed elements. Caught live by our own acceptance probe; the three-arg form is correct everywhere.

Failures are typed: invalid_request (400 — fix and resend), unavailable (503 — transient, back off and retry), provider_error (502 — the upstream embedder rejected a request the platform believed valid; short provider message included). Prompt tokens roll into the project's month usage (tagged utility_embed); there is no separate platform credit rate.

Spoken audio — use synthesizeSpeech, not /call

The audio sibling of extractJson: text → a downloadable mp3 file, OUTSIDE the companion and OUTSIDE a realtime call. /call is live ConvAI (a WebRTC session); when you need an audio file for a headless / Ops job — a reference clip for a downstream video / lip-sync API, a pre-rendered notification — this is the stateless path.

const { data, usage } = await admin.synthesizeSpeech({
  text: 'Hey — just wanted to share this moment with you.',
  voice: 'hd_ByhETIclHirOlWnWKhHc' // a catalog id OR providerVoiceId from listVoices()
});
// data.audioBase64 is the mp3 — decode and persist it (never hand a provider URL
// to a client). In Node: Buffer.from(data.audioBase64, 'base64'); in a browser:
// Uint8Array.from(atob(data.audioBase64), (c) => c.charCodeAt(0)).
console.log(data.mimeType, data.voiceId, data.provider, usage.characterCount);

voice is the same id you pass to a call's voice (from listVoices()), restricted to enabled catalog rows plus the built-in OpenAI roster — so the Ops sample and the live call use one authoritative voice. Only mp3 is produced in v1. Failures are typed: voice_not_found (404), text_too_long (413), unsupported_format (400), unavailable (503 — no TTS provider configured for that voice, or the voice catalog is temporarily unreachable; retry), provider_error (502). Synthesis bills on your provider account like the other voice planes — there is no separate platform credit rate; it is bounded by an input cap and the shared per-IP utility rate limit.

Every skill knob (setSkillRate, setSkillDailyCap, grantSkill, setSkillAutoRunTools) returns SkillKnobResult<T> — the knob you set plus reprovisioned (instances the new def reached) and truncated. A knob only binds a running agent once the def reaches its instance, so truncated: true means the sweep hit its cap (100 agents / 200 instances) and the remainder still hold the old def; they do not catch up on their next session. Treat a revoking call that returns truncated: true as a partial revocation and re-issue it.

Agent Plugins (agent-plugins.org) — import & export

Pouchy consumes and emits the cross-vendor Agent Plugins 1.0.0 packaging standard. Import takes the plugin as an explicit file map (read the directory yourself — no archive upload): skills/<dir>/SKILL.md installs as a docs-only skill, mcp.json streamable-http servers connect through the MCP path, and a Pouchy-exported plugin round-trips losslessly via its extensions["ai.pouchy"] manifest. stdio/sse transports and bundled script files are skipped per component with a reason (the platform is serverless — the spec requires clients to support only one transport). Declared mcp headers are never forwarded — the spec itself calls them visible package data; store real credentials with putCredentials after install. Every imported component runs the same install safety judge as the native install paths.

import { createAdminClient } from '@pouchy_ai/admin-sdk';

const admin = createAdminClient({ adminKey: process.env.POUCHY_ADMIN_KEY! });

// The plugin travels as an explicit { path, content } file map — read the
// plugin directory with your runtime's fs and preserve relative POSIX paths.
const result = await admin.importAgentPlugin({
  files: [
    {
      path: 'plugin.json',
      content: JSON.stringify({
        $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
        name: 'hello-plugin'
      })
    },
    {
      path: 'skills/greet/SKILL.md',
      content: '---\nname: greet\ndescription: Greet the user and offer help.\n---\n\nGreet the user warmly.\n'
    }
  ]
});
console.log(result.installed); // [{ slug: 'greet', kind: 'http', component: 'skills/greet' }]
for (const s of result.skipped) console.warn(`${s.component}: ${s.reason}`);

// Export any installed skill as a conformant plugin (write it out / zip it).
const pkg = await admin.exportAgentPlugin('greet');
for (const f of pkg.files) console.log(f.path, f.content.length);

Options

createAdminClient({
  adminKey: 'pchy_admin_…',   // required
  baseUrl: 'https://pouchy.ai/v1/admin', // optional (self-host / staging)
  fetch: myFetch,             // optional (Node <18, or tests)
  timeoutMs: 30_000           // optional per-request timeout — see below
});

timeoutMs defaults to 30s, except for the requests whose server handler declares maxDuration: 300 and therefore answers only when the work is finished — POST /knowledge, POST /knowledge/file, POST /knowledge/url (chunk + summarize + embed, after OCR / Whisper / vision or a page fetch), DELETE /users/{instanceId} (recursive GDPR erasure) and POST /capabilities/{name}/test-action (the Action protocol verifier makes REAL deliveries to your endpoint, and an abort mid-test cannot undo the ones already made). Those default to 310s (LONG_WORK_TIMEOUT_MS), so a client abort can only ever mean "the server really is hung", never "the server is still working" — a shorter deadline there reports a failure for an ingest that is succeeding, and the retry it invites races the still-running first one (or re-runs a side-effecting test whose first verdict is never read). The list is LONG_WORK_REQUESTS, exported, and a drift test binds it to the routes.

Setting timeoutMs explicitly always wins and applies to every request, long or short. requestDeadlineMs(method, path) returns the default a given request would use.

timeoutMs: 0 disables the deadline — the request runs unbounded. This is the same contract as the companion JS SDK's requestTimeoutMs: 0, so the idiom carries between the two packages. (Before 0.7.1 a 0 aborted every request on the next tick.)

Errors

Every method throws AdminApiError on failure — a non-2xx response, a network error, or a timeout. Transport failures (network/DNS/timeout) carry status: 0; HTTP errors carry the real status and the server's error string:

import { AdminApiError } from '@pouchy_ai/admin-sdk';
try {
  await admin.getAgent('nope');
} catch (e) {
  if (e instanceof AdminApiError) console.error(e.status, e.message); // 404 "unknown agent"
}

On a persisted 5xx the message is sanitized server-side ("An unexpected error occurred.") and AdminApiError.errorId carries the server's err_… lookup reference (0.31.1) — quote it in a support request; it is what resolves to the real cause.

A transport failure has no status, no code and no errorId, so since 0.32.1 its message names the request that failed:

GET /agents — request timed out after 30000ms
POST /knowledge/url — network error: fetch failed

Every method shares one request funnel, so without that prefix a timeout on a five-minute knowledge ingest and a timeout on listAgents printed the same line. HTTP errors are unchanged: the message is the server's own error string ("unknown agent"), and only its no-body fallback names the request (GET /agents → HTTP 502).

Failure codes (409)

Every machine-readable failure on this API is a 409, so status separates none of them. AdminApiError.code is the discriminator — switch on it rather than string-matching message, which is prose and may be reworded:

import { AdminApiError } from '@pouchy_ai/admin-sdk';

// `runId` and `token` come from the run you are answering — the
// `agent.run_awaiting` webhook carries both, or read them off `getRun`.
declare const runId: string;
declare const token: string;

try {
  await admin.resumeRun(runId, { approved: true, token });
} catch (e) {
  if (!(e instanceof AdminApiError)) throw e;
  switch (e.code) {
    case 'stale_token': {
      // The run moved on to a different question. Re-read and answer the
      // current one — this is the retryable branch.
      const { run } = await admin.getRun(runId);
      if (run.awaiting) await admin.resumeRun(runId, { approved: true, token: run.awaiting.token });
      break;
    }
    case 'run_not_parked':
      // Someone else decided, or the run was cancelled. Do NOT retry.
      break;
    default:
      throw e;
  }
}

The vocabulary is exported as ADMIN_ERROR_CODES (and the AdminErrorCode type), and is append-only:

| code | route | what to do | |---|---|---| | schedule_limit_reached | createSchedule | delete or disable a schedule, retry | | channel_limit_reached | createChannel | delete an unused connector, retry | | webhook_limit_reached | createWebhook | delete an unused endpoint, retry | | run_limit_reached | createRun | wait for a run to finish, or cancelRun one | | reembed_required | knowledge config | clear + re-ingest to switch embedding model | | run_terminal | cancelRun | the run already finished — nothing to cancel | | run_not_parked | resumeRun | already decided; stop retrying | | stale_token | resumeRun | getRun, then answer the current awaiting.token | | run_not_waiting | signalRun | the wait ended; stop retrying | | event_mismatch | signalRun | the run wants a different key; your event is early or wrong | | one_shot_spent | updateSchedule | the one-shot already fired — create a new schedule instead of re-enabling | | ghost_row | updateSchedule | a legacy fieldless row that can never run — delete it, create a new schedule |

code is undefined on every failure the server did not tag (400s, 404s, the 429, transport errors), so always keep a default branch. The type has a (string & {}) arm on purpose: a code newer than your installed build still arrives as a readable string. Available from 0.10.0.

Throttling (429)

Writes are rate-limited per IP — 120 non-GET requests per minute across the whole /v1 plane — so a migration loop over agents, users or skills will hit it. On a 429, AdminApiError.retryAfter carries the server's backoff in seconds (from the body's retryAfter / retryAfterSec, or the Retry-After header). It is undefined on every other failure, and undefined — never 0 — when the server named no delay, so ?? your own floor rather than retrying immediately:

async function withBackoff<T>(call: () => Promise<T>, tries = 5): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await call();
    } catch (e) {
      if (!(e instanceof AdminApiError) || e.status !== 429 || i >= tries) throw e;
      await new Promise((r) => setTimeout(r, (e.retryAfter ?? 5) * 1000));
    }
  }
}

await withBackoff(() => admin.updateAgent(id, { status: 'published' }));

Reads (GET) are not covered by that bucket. retryAfter is available from 0.7.0.

Surface

| Area | Methods | | --- | --- | | Agents | listAgents · createAgent · getAgent · updateAgent · deleteAgent | | Agent versions | listAgentVersions · getAgentVersion · diffAgentVersions({ from?, to? }) · rollbackAgent · getAgentPromotion · promoteAgent (staging→prod) | | Voices | listVoices({ gender?, age?, locale? }) — catalog for programmatic voice selection (each CatalogVoice carries age) | | Secret keys | listKeys · createKey · revokeKey · rotateKey (24 h grace) | | End users | listUsers({ limit?, cursor? }) (cursor-paginated — the response's nextCursor feeds the next page; filter variants: external_user_id / external_user_prefix) · setUserSuspended · deleteUser · getUserWallet · getUserTraces · importUsers · exportUser · getUserSessions · getUserTurns | | Knowledge | listKnowledge · ingestKnowledge · ingestKnowledgeFile (PDF/audio/video/image) · ingestKnowledgeUrl (web page) · searchKnowledge (recall probe) · deleteKnowledge | | Skills | listSkills · installSkill · updateSkill · setSkillRate · setSkillDailyCap · grantSkill (free-HTTP) · setSkillAutoRunTools (免确认工具 — required to arm an MCP skill on an instance) · compileSkill (prose→tools) · uninstallSkill · importAgentPlugin / exportAgentPlugin (Agent Plugins 1.0.0 interop) | | Credentials | listCredentials · putCredentials · deleteCredentials | | Channels | listChannels · createChannel · getChannel · updateChannel · deleteChannel | | Schedules | listSchedules · createSchedule · getSchedule · updateSchedule · deleteSchedule | | Durable runs | listRuns · createRun · getRun · cancelRun · resumeRun · signalRun | | Data capabilities | listCapabilities (heads + MASKED signing status) · publishCapability (TYPED CapabilityDeclaration; immutable next version, idempotent on content) · setCapabilityDisabled (per-capability live revoke) · listCapabilityVersions (history; rollback = republish an old declaration) · testReadCapability · testActionCapability({ confirmDuplicates: true, … }) (REAL deliveries incl. intentional duplicates — 428 without consent) · listActionExecutions (the durable Action journal, incl. authorizedBy: 'automation') · listEventReceipts (receipts + wake trail) | | Webhooks | listWebhooks · createWebhook · updateWebhook · rotateWebhookSecret · deleteWebhook · testWebhook · redeliverWebhook | | Reporting | getUsage · getUsageHistory · getBilling · getTracesSummary · getRecentTraces · getLogs · getProject · updateProject | | Utilities | extractJson (structured JSON from prose, schema-shaped) · synthesizeSpeech (short TTS clip in a catalog voice, base64 mp3) | | Escape hatch | request(method, path, body?) — any endpoint not yet typed |

Channel types are checked at compile time: createChannel takes a CreatableChannelType (the platform's 81-transport union — every member has an adapter since 0.30.0), and secret is a named ChannelSecretInput. Per-transport secret.extra fields are listed in https://pouchy.ai/docs/channel-setup.

Telegram and Discord register themselves. On create — and again on a rotate: true or a secret replacement, which invalidate what the provider holds — the server calls Telegram's setWebhook / registers Discord's slash command with secret.token, and reports the outcome as a ChannelProvisionResult on provision. Two things to know:

  • setWebhook replaces rather than adds. If you already manage your bot's webhook, pass autoProvision: false or the create will overwrite it.
  • The connector is durable before any provider call, so this never fails the request — a failed outcome is reported, not thrown. Branch on code (a stable vocabulary, ChannelProvisionCode), never on detail; manualCommand is the equivalent curl with secrets left as <PLACEHOLDER>.
import { createAdminClient } from '@pouchy_ai/admin-sdk';

const admin = createAdminClient({ adminKey: process.env.POUCHY_ADMIN_KEY! });

const { connector, provision } = await admin.createChannel({
  type: 'telegram',
  agentId: process.env.AGENT_ID!,
  secret: {
    token: process.env.TELEGRAM_BOT_TOKEN!,
    inboundSecret: process.env.TELEGRAM_SECRET_TOKEN!
  }
});

if (provision?.status === 'failed') {
  // The connector exists — only the webhook registration didn't land.
  console.warn('register by hand:', provision.detail, provision.manualCommand);
}
console.log(connector.id);

Capability signing-key management is deliberately not mirrored here: the one-time pcsk_/pesk_ plaintext reveal stays a human act on the owner plane (dashboard / owner API). listCapabilities returns masked key status only.

Durable runs

A schedule fires one turn and is done. A run is long-lived agent work: checkpointed after every step and advanced by the platform across as many ticks as it needs, so it survives restarts and can span minutes to hours.

Every mutation answers 202, not 200/201 — the work happens on a later tick, never inside your request:

const agentId = 'agt_123';
const { run } = await admin.createRun({
  agentId,
  externalUserId: 'user-42',
  goal: 'Reconcile the overnight invoices and flag anything over $500.',
  steps: [
    { id: 'gather', kind: 'agent_turn' },
    { id: 'ok', kind: 'human_approval', input: { prompt: 'Approve the flagged refunds?' } },
    { id: 'apply', kind: 'agent_turn' }
  ]
});
console.log(run.id, run.status); // → 'queued'

A subtask_fanout step runs at most 4 subtasks (input.subtasks). Declaring more is a 400 naming the cap rather than a silent trim — split the remainder into a second fan-out step. Entries without a non-empty goal are ignored and do not count against the cap.

When a run reaches a human_approval step it parks: it holds no lease and leaves the platform's due window, so nothing will move it until you answer. You learn about it either way — the agent.run_awaiting webhook pushes the token, and getRun always carries it:

const runId = 'run_abc';
const { run } = await admin.getRun(runId);
if (run.status === 'awaiting_human' && run.awaiting) {
  await admin.resumeRun(runId, { approved: true, token: run.awaiting.token });
}

The token is a fencing token, not a secret — authorization is your admin key. It exists so a stale or replayed decision cannot land on a question the run has already moved past (you get 409 stale_token).

An await_event step parks the same way until you call signalRun with the matching key; a mismatch answers 409 event_mismatch, which lets a webhook retry tell "too late" from "rejected".

OpenAPI

The machine-readable contract is served at GET https://pouchy.ai/v1/admin/openapi (OpenAPI 3.1, public). Import it into Postman / Swagger UI, or generate a client in any other language with openapi-generator.

License

SEE LICENSE IN LICENSE.