@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.
Maintainers
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-sdkQuickstart
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:
- Persist
res.modelnext 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 answers400— never a silent substitute — so a platform model swap surfaces on your side as an explicit reindex signal. - Texts are never silently truncated. An over-cap text (> 8000 chars) is a
400naming 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 failedEvery 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:
setWebhookreplaces rather than adds. If you already manage your bot's webhook, passautoProvision: falseor the create will overwrite it.- The connector is durable before any provider call, so this never fails the
request — a
failedoutcome is reported, not thrown. Branch oncode(a stable vocabulary,ChannelProvisionCode), never ondetail;manualCommandis 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.
