@puku-ai/sdk
v4.0.7
Published
PukuAI client SDK
Readme
@puku-ai/sdk
Official TypeScript SDK for the Puku AI client SDK.
The client class is PukuAI. Method names, request shapes, stream events,
and error classes follow the Anthropic Messages API conventions.
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI({
apiKey: process.env.PUKU_API_KEY!,
baseURL: process.env.PUKU_BASE_URL!,
});
const msg = await client.messages.create({
model: "puku-ai-2.8",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello!" }],
});
console.log(msg.content[0].text);Contents
Install
npm install @puku-ai/sdk
# or
pnpm add @puku-ai/sdk
# or
yarn add @puku-ai/sdkThe package targets Node.js ≥ 18 and ships with TypeScript declarations out of the box.
Configuration
Two environment variables, both required:
| Env var | Purpose |
| --------------- | -------------------------------------- |
| PUKU_API_KEY | Puku API key (sent as X-Api-Key) |
| PUKU_BASE_URL | Puku API gateway URL (no fallback) |
const client = new PukuAI({
apiKey: process.env.PUKU_API_KEY!,
baseURL: process.env.PUKU_BASE_URL!,
maxRetries: 2,
timeout: 600_000,
defaultHeaders: { "x-app": "my-app" },
});Defaults: maxRetries = 2, timeout = 600_000 ms (10 min).
Supported models
Exactly three model names are accepted by the Puku gateway. Any other string returns a model-not-found error.
| Model | Family | Notes |
|---------------|----------|-------|
| puku-ai-2.7 | Puku AI | Cheaper, lower-latency. |
| puku-ai-2.8 | Puku AI | Default for most workloads. |
Usage
Blocking
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI();
const msg = await client.messages.create({
model: "puku-ai-2.8",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello!" }],
});
console.log(msg.content[0].text);Streaming
const stream = client.messages.stream({
model: "puku-ai-2.8",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello!" }],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
// After the loop:
const fullText = await stream.finalText();
const finalMessage = await stream.finalMessage();Errors
import PukuAI from "@puku-ai/sdk";
try {
await client.messages.create(...);
} catch (err) {
if (err instanceof PukuAI.RateLimitError) {
// rate limited — back off and retry
} else if (err instanceof PukuAI.AuthenticationError) {
// bad api key
} else if (err instanceof PukuAI.PukuError) {
// any other SDK error (base class — all errors extend this)
console.error(err.status, err.type, err.message);
}
}All error classes are static members of the PukuAI class,
PukuAI.PukuError(branded base — every error the SDK throws is an instance of this)PukuAI.APIErrorPukuAI.APIConnectionErrorPukuAI.APIConnectionTimeoutErrorPukuAI.APIUserAbortErrorPukuAI.NotFoundErrorPukuAI.ConflictErrorPukuAI.RateLimitErrorPukuAI.BadRequestErrorPukuAI.AuthenticationErrorPukuAI.InternalServerErrorPukuAI.PermissionDeniedErrorPukuAI.UnprocessableEntityError
Working features
Every feature below was exercised against the live Puku gateway at
PUKU_BASE_URL= with a pk_live_* API key (see
.env and .env.staging.example).
Verified against the live gateway
| Feature | Status | Where |
|----------------------------------------------------|--------|-------|
| Blocking messages.create + message.usage | ✅ works | client.messages.create() |
| Streaming messages.stream() + SSE iteration | ✅ works | client.messages.stream() |
| stream.finalText() | ✅ works | MessageStream |
| stream.finalMessage() | ✅ works | MessageStream |
| stream.on("text", cb) listener | ✅ works | MessageStream |
| client.beta.messages.toolRunner(...).runUntilDone() | ✅ works | client.beta.messages |
| client.messages.countTokens | ✅ works | count-tokens.ts |
| client.messages.batches.create / retrieve / results | ✅ works | batch-results.ts (D1-backed storage) |
| Server-side web_search (both streaming + non-streaming variants) | ✅ works | web-search.ts, web-search-stream.ts (gateway forwards tool_use to puku-research-gateway and maps back to web_search_tool_result) |
| Structured outputs (output_config.format) — zod / json-schema / raw / standard-schema | ✅ works (non-streaming; raw occasionally flaky) | structured-outputs-*.ts |
| ToolError class (exceptions from the runner) | ✅ works | helpers/beta |
| MCP helpers (mcpTool, mcpTools, mcpMessage, mcpMessages, mcpResourceToContent, mcpResourceToFile) | ✅ exported | helpers/beta/mcp |
| Agent toolset (betaAgentToolset20260401, betaBashTool, betaReadTool, betaWriteTool, betaEditTool, betaGlobTool, betaGrepTool) | ✅ exported | tools/agent-toolset |
| Full error hierarchy (APIError, RateLimitError, AuthenticationError, …) | ✅ works | static on PukuAI |
Known gateway gaps
| Feature | Status | Note |
|--------------------------------------|--------|------|
| Structured-outputs JSON parser (output_config.format) — streaming variant | ⚠ flaky | Passes most runs but puku-ai-2.8 / puku-ai-2.7 occasionally emit a duplicate JSON key in the payload which the SDK's Zod parser rejects. Re-running usually passes — not a regression, just model-output variance. |
| Structured-outputs JSON parser (output_config.format) — raw variant | ⚠ flaky | Same root cause: puku-ai-2.8 occasionally emits non-JSON prose (e.g. I computed …) or a malformed JSON payload (e.g. an unterminated string) inside the synthetic tool call, which the SDK's JSON.parse rejects. Re-running usually passes — not a regression, just model-output variance. |
Beta managed-agents surface (preview)
The Puku gateway serves Anthropic-shaped responses for the beta managed-agents namespace so SDK code that calls them does not 404. These exist so SDK examples round-trip against the live gateway while the full managed-agents runtime track is reopened — they are not a production control plane.
| Surface | Status | What you can do with it |
|--------------------------------------|--------|-------------------------|
| client.beta.sessions.* | ✅ works | Run a managed-agent loop — create session, post events.send, stream events.stream. The gateway drives the LLM turn and emits the same Anthropic-shaped SSE events the SDK's SessionToolRunner consumes. |
| client.beta.agents.* | ⚠ limited | CRUD round-trip. agents.create returns a populated BetaAgent shape but no remote agent execution. |
| client.beta.environments.* | ⚠ limited | POST /environments returns {id, status:'ready'} synchronously — no creating → ready polling. |
| client.beta.environments.*.work.{poll,ack,heartbeat,stop,retrieve,update} | ⚠ limited | The work queue round-trips so WorkPoller/EnvironmentWorker terminate. One synthetic work item per session. |
| client.beta.vaults.* | ⚠ limited | CRUD + credentials.create. Secret tokens are dropped — vault_ids passed to sessions.create are ignored (sessions don't need credentials in puku). |
| client.beta.skills.{create,list,retrieve,delete} | ⚠ limited | Multipart upload + metadata only. Accepts files and files[] field names. |
| client.beta.skills.versions.{create,list,retrieve,delete} | ⚠ limited | Multipart upload + metadata only. Idempotent on (skill_id, version) — 409 on duplicate. |
| client.beta.skills.versions.download | ⚠ limited | Returns a 22-byte empty-zip body so response.blob() decodes a real Blob. Real archive bytes are not stored. |
| client.files.{upload,list,retrieve,download,delete} (non-beta /v1/files) | ⚠ limited | Bytes held in-memory only — not persisted anywhere durable. |
Working example: managed-agent session with toolRunner
This is the loop client.beta.sessions.* actually drives for you. You create
a session, post a user message, open the event stream, and the SDK's
toolRunner dispatches any agent.tool_use events back through
events.send automatically.
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI();
// 1. Create an environment + an agent + a session.
const env = await client.beta.environments.create({ name: "demo" });
const agent = await client.beta.agents.create({
name: "weather-bot",
model: "puku-ai-2.8",
environment: { type: "cloud", id: env.id },
tools: [
// Tools the gateway's tool registry will resolve at agent-turn time.
{ type: "agent_toolset_20260401" },
],
});
const session = await client.beta.sessions.create({
agent_id: agent.id,
environment_id: env.id,
});
// 2. Define a runnable tool the gateway will round-trip.
const getWeather = {
name: "get_weather",
description: "Return the current temperature for a city.",
input_schema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
run: async (input: { city: string }) =>
JSON.stringify({ temp_c: 22.4, city: input.city }),
};
// 3. Post a user message and stream the agent turn.
await client.beta.sessions.events.send(session.id, {
type: "user.message",
content: [{ type: "text", text: "What's the weather in Tokyo?" }],
});
for await (const event of client.beta.sessions.events.stream(session.id)) {
// `agent.message` is the final assistant turn; everything before it
// is either tool_use / tool_result round-trips or status transitions.
if (event.type === "agent.message") {
console.log("agent:", event.content);
}
}
// 4. Clean up.
await client.beta.sessions.delete(session.id);
await client.beta.agents.delete(agent.id);
await client.beta.environments.delete(env.id);Working example: skill upload + version
The skills router accepts multipart upload and round-trips a single version
per skill. Real archive bytes are not stored — versions.download returns a
22-byte empty zip so response.blob() succeeds.
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI();
// Upload a skill.
const skill = await client.skills.create({
display_name: "my-skill",
files: [new File(["# SKILL"], "SKILL.md", { type: "text/markdown" })],
});
// Add a version (auto-generated timestamp, or pass one explicitly).
const v1 = await client.beta.skills.versions.create(skill.id, {
files: [new File(["# v1"], "SKILL.md", { type: "text/markdown" })],
});
// List versions, retrieve one, download (returns an empty zip blob).
const page = await client.beta.skills.versions.list(skill.id);
const fetched = await client.beta.skills.versions.retrieve(skill.id, v1.version);
const blob = await client.beta.skills.versions.download(skill.id, v1.version);
console.log(page.data.length, fetched.version, blob.size); // 1, "20260101T000000Z", 22
// Cleanup — delete cascades to all versions.
await client.beta.skills.delete(skill.id);Working example: file upload + download (non-beta /v1/files)
client.files.* is the non-beta Anthropic Files API stub. Bytes are held in
memory and not persisted anywhere durable.
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI();
const file = await client.files.upload({
file: new File(["hello"], "hello.txt", { type: "text/plain" }),
});
console.log(file.id, file.filename, file.size_bytes); // file_xxx, "hello.txt", 5
const list = await client.files.list();
const blob = await client.files.download(file.id);
console.log(await blob.text()); // "hello"
await client.files.delete(file.id);Example: full tool-runner loop
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI({
apiKey: process.env.PUKU_API_KEY!,
baseURL: process.env.PUKU_BASE_URL!,
});
// A runnable tool. Puku-ai/sdk accepts the same BetaRunnableTool shape
// (tool def + run + parse).
const getWeather = {
name: "get_weather",
description: "Get the current temperature for a city, in Celsius.",
input_schema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
run: async (input) => JSON.stringify({ temp_c: 22.4, city: input.city }),
parse: (raw) => JSON.parse(raw),
};
const finalMessage = await client.beta.messages
.toolRunner({
model: "puku-ai-2.8",
max_tokens: 512,
messages: [{ role: "user", content: "What's the weather in Tokyo?" }],
tools: [getWeather],
})
.runUntilDone();
console.log(finalMessage.content[0].text);
// -> "It looks like the weather service returned an error. Could you try
// again in a moment? ..." (or a successful summary, depending on
// the model + tool)
console.log(finalMessage.usage);Example: streaming with text listener
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI();
const stream = client.messages.stream({
model: "puku-ai-2.8",
max_tokens: 128,
messages: [{ role: "user", content: "Say hello in three languages." }],
});
let buf = "";
stream.on("text", (chunk) => { buf += chunk; });
stream.on("error", (err) => { console.error("stream error:", err); });
for await (const _ of stream) { /* drain */ }
const text = await stream.finalText();
const final = await stream.finalMessage();
console.log(text, final.usage);Example: MCP helpers
import PukuAI, { mcpTools, mcpMessage, mcpMessages, mcpResourceToFile } from "@puku-ai/sdk";
const client = new PukuAI();
// `mcpTools` converts an MCP client from @modelcontextprotocol/sdk into
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
const mcp = new Client({ name: "demo", version: "0.0.0" }, { capabilities: {} });
const [clientT] = InMemoryTransport.createLinkedPair();
await mcp.connect(clientT);
const tools = await mcpTools({ mcpClient: mcp });
const reply = await client.beta.messages.create({
model: "puku-ai-2.8",
max_tokens: 512,
mcp_servers: [{ type: "url", url: "https://mcp.example.com/sse", name: "example" }],
messages: [{ role: "user", content: "Find any open PRs." }],
});Example: agent toolset (bash / read / write / edit / glob / grep)
currently ignore this
The SDK ships the upstream tools/agent-toolset surface — the same BetaRunnableTool[]
factory used by puku-cli's agent mode to give the model a sandboxed shell + filesystem.
import { betaAgentToolset20260401 } from "@puku-ai/sdk";
const tools = betaAgentToolset20260401({ workdir: "/tmp/sandbox" });
// -> [bash, read, write, edit, glob, grep]
const runner = client.beta.messages.toolRunner({
model: "puku-ai-2.8",
max_tokens: 1024,
messages: [{ role: "user", content: "List /tmp/sandbox and grep for TODO" }],
tools,
});
const final = await runner.runUntilDone();Drop individual tools in or out:
const tools = betaAgentToolset20260401({ workdir: "/tmp/sandbox" })
.filter((t) => t.name !== "bash"); // no shell accessOr pick the individual factories:
import { betaReadTool, betaBashTool } from "@puku-ai/sdk";
const tools = [betaReadTool({ workdir: "/tmp/sandbox" })];
setupSkills,resolveSkillVersion, andextractSkillArchiveround-trip against/v1/skills/{id}/versions/{version}+/v1/sessions/{id}. The gateway now serves those endpoints (multipart upload + metadata in-memory, download returns a 22-byte empty zip), so the helpers no longer 404 — butextractSkillArchivewill receive an empty zip body and cannot extract real archive contents. The pure archive-validation helpers (assertSafeMemberNames,assertNoSpecialMembers,runArchiveTool,archiveTopDir,readHead) stay available — they don't touch the gateway.
Namespace
Resource and helper types live on the PukuAI class so they can be used
without re-importing:
import PukuAI from "@puku-ai/sdk";
type M = PukuAI.Messages.Message;
type Mod = PukuAI.Models.ModelInfo;
type Beta = PukuAI.Beta.Messages.BetaMessage;
type Skill = PukuAI.Beta.Skills.SkillRetrieveResponse;Static helpers:
PukuAI.HUMAN_PROMPT; // "\n\nHuman:"
PukuAI.AI_PROMPT; // "\n\nAssistant:"
PukuAI.toFile(...); // multipart helper
PukuAI.VERSION; // SDK versionESM and CJS
The default export behaves differently depending on the module system.
ESM (import PukuAI from "@puku-ai/sdk"): the default export is the
PukuAI class. Construct it with new:
import PukuAI from "@puku-ai/sdk";
const client = new PukuAI({ apiKey, baseURL });CJS (require("@puku-ai/sdk")): the build applies a small callable shim
(see scripts/build.ts → patchCallableCjs) that wraps module.exports in a
function which delegates to new exports.default(...). This means both
of these work:
const PukuAI = require("@puku-ai/sdk");
// Callable form (uses the shim):
const client = PukuAI({ apiKey, baseURL });
// Or as a class (`.default` is the same `PukuAI` class):
const client = new PukuAI({ apiKey, baseURL });Use import PukuAI from "@puku-ai/sdk" in new code; the CJS callable form
License
MIT
