Skip to content

SDK reference

Terminal window
npm i @persql/sdk
import { PerSQL } from "@persql/sdk";
const persql = new PerSQL({
token: process.env.PERSQL_TOKEN!,
baseURL: "https://api.persql.com", // optional, this is the default
});
OptionTypeDefaultNotes
tokenstringrequired (unless local)psql_live_… bearer
baseURLstringhttps://api.persql.comOverride for self-hosted / staging
fetchtypeof fetchglobalThis.fetchInject your own fetch (useful in Node 18-, edge runtimes)
localstringPath to a SQLite file or ":memory:". Routes query / batch / tables / explain / schema through better-sqlite3 (lazy-loaded optional peer dep) instead of HTTP. See Local testing.
const db = persql.database("acme/orders");
// or
const db = persql.database("acme", "orders");

Returns a PerSQLDatabase.

const result = await db.query<{ id: number; email: string }>(
"SELECT id, email FROM customers WHERE id = ?",
[42],
{ idempotencyKey: "fetch-customer-42-once" }
);
console.log(result.data); // [{ id: 42, email: "ada@example.com" }]
console.log(result.columns); // ["id", "email"]
console.log(result.rows); // [[42, "ada@example.com"]]
console.log(result.rowsRead); // 1
const results = await db.batch([
{ sql: "INSERT INTO orders (id, total) VALUES (?, ?)", params: ["o1", 99.5] },
{ sql: "INSERT INTO orders (id, total) VALUES (?, ?)", params: ["o2", 12.0] },
{ sql: "SELECT COUNT(*) AS n FROM orders" },
]);
console.log(results[2].data); // [{ n: 2 }]

options.transaction: true wraps the batch — first error rolls back everything.

Sugar for db.batch(stmts, { transaction: true }). See Batch and transactions.

const tables = await db.tables();
// [{ name: "orders", rowCount: 1284 }, ...]

Discoverability — describe, search, doctor

Section titled “Discoverability — describe, search, doctor”

The three calls an agent should make on a fresh database, in order.

Returns the entire schema graph plus any stored semantic descriptions in one round-trip — designed to be JSON-stringified straight into an LLM prompt.

const schema = await db.describe();
// {
// databaseDescription: "Order data for acme",
// tables: [
// {
// table: "orders",
// description: "One row per checkout",
// columns: [
// { name: "id", type: "INTEGER", pk: true, notNull: true, description: "" },
// { name: "customer_id", type: "INTEGER", pk: false, notNull: true,
// description: "FK → customers.id" },
// ],
// foreignKeys: [{ from: "customer_id", toTable: "customers", toColumn: "id" }],
// },
// ],
// }

Persist semantic docs (admin token role required). Pass any subset.

await db.setDescription({
databaseDescription: "Order data for acme",
tables: [
{
table: "orders",
description: "One row per checkout",
columns: [
{ column: "customer_id", description: "FK → customers.id" },
],
},
],
});

Natural-language ranked search over table names, column names, and stored descriptions. Use when you don’t know the schema cold.

const { hits } = await db.search("customer billing addresses");
// [{ kind: "table", table: "addresses", description: "...", score: 6 }, ...]
OptionTypeDefaultNotes
limitnumber251–100

Lints the schema for issues that hurt LLM consumption. Read-only — emits suggestions, never modifies anything.

const { findings } = await db.doctor();
// [
// { code: "missing_pk", severity: "warning", table: "events", column: null,
// message: "Table \"events\" has no primary key.",
// suggestion: "Add an integer PRIMARY KEY..." },
// { code: "ambiguous_column_name", severity: "info", ... },
// ]

Pair with CI: fail the build if any severity === "error" finding appears.

Engine telemetry — one entry per /v1/query and /v1/batch call. The same data also lives inside the database as _persql_meta_query_log, so you can JOIN against it from your own SQL.

const { data } = await db.queryLog({
since: new Date(Date.now() - 60 * 60 * 1000),
status: "error",
});
OptionTypeNotes
since / untilDate | stringFilter by timestamp
tokenIdstringPer-token cost partitioning
status”ok” | “error”
cursor / pageSizestring / numberPagination

One-shot lease: create-or-reset a branch with a TTL and mint a scoped token in the same call. See Claim a branch.

const lease = await db.branches.claim({
purpose: "agent-run-fix-issue-742",
ttlSec: 3600, // default
role: "write", // default
});
// lease.branchRef, lease.token, lease.expiresAt

db.proposals.propose(sql, options?) / db.proposals.apply(token)

Section titled “db.proposals.propose(sql, options?) / db.proposals.apply(token)”

Pre-flight a write. propose() validates SQL via EXPLAIN, estimates affected rows, and returns a single-use executionToken. apply() runs it. See Propose / apply.

const plan = await db.proposals.propose(
"UPDATE users SET tier = ? WHERE company_id = ?",
{ params: ["enterprise", 42] }
);
// plan.estimatedAffectedRows, plan.plan, plan.executionToken
const result = await db.proposals.apply(plan.executionToken);

Approvals are a first-class primitive — get status without consuming, wait for a reviewer, or subscribe to changes. See Approval rules.

// Throws ApprovalRequiredError carrying approvalToken + approvalUrl.
try {
await db.query("UPDATE production_users SET …");
} catch (e) {
if (e instanceof ApprovalRequiredError) {
// 1) Look up status without redeeming:
const status = await db.approvals.get(e.approvalToken);
// status.status: "pending" | "approved" | "denied"
// 2) Block until a reviewer decides (or expiry):
const final = await db.approvals.poll(e.approvalToken, {
intervalMs: 2000,
timeoutMs: 10 * 60 * 1000,
});
// 3) Once approved, run the original write:
if (final.status === "approved") {
const result = await db.approvals.redeem(e.approvalToken);
}
}
}
// Poll-based push for clients that can't host a webhook:
const sub = db.approvals.subscribe([token1, token2], {
onApprovalResolved: ({ approvalToken, status }) => { /* … */ },
});
// sub.stop() to cancel

For push-style delivery, prefer the approval_required / approval_resolved webhook events (see Webhooks).

Manage require_approval / deny rules from code. List is open to any bearer with read access; create and delete require an admin-role token.

const rules = await db.approvalRules.list();
const rule = await db.approvalRules.create({
tableGlob: "production_*",
action: "require_approval",
note: "All production tables need human sign-off",
});
await db.approvalRules.delete(rule.id);

Mint a single-use handoff token (phand_…) pinned to one (database, branch, role). Used by orchestrator agents to delegate to sub-agents. See the sub-agent handoff recipe.

const { token } = await db.branches.pin("pr-42", {
role: "write", // "read" | "write" | "admin"
ttlSec: 900, // 60–86400, default 900
});

Sub-agent side. Redeems a phand_… for a regular psql_live_… token scoped exactly to the pinned (database, branch, role).

const sub = await PerSQL.fromHandoff(handoffToken);
const db = sub.database(sub.handedOff.namespaceSlug, sub.handedOff.databaseSlug);

One fat SQL tool definition for every major function-calling API. See Tool use.

const tool = db.asTool("query_orders");
// tool.anthropic — Anthropic-shaped tool
// tool.openai — OpenAI Chat Completions-shaped tool
// tool.aiSdk() — Vercel AI SDK { [name]: { description, inputSchema, execute } }
// tool.mastra() — Mastra { [name]: { id, description, inputSchema, execute } }
// tool.langchain() — LangChain / LangGraph [{ name, description, schema, invoke }]
// tool.openaiAgents() — OpenAI Agents SDK [{ type, name, description, parameters, invoke }]

Same shapes as db.asTools(), just one tool instead of a per-table bundle.

Full agent-surface tool bundle. Per table: select_/count_/describe_/insert_/update_/delete_<table>. Discovery: describe_database, search_schema, schema_doctor. Safety: propose_mutation, apply_mutation. Telemetry: recent_queries. Branches: claim_branch, branches_list/create/delete/preview_merge/merge. Fallback: sql_query.

The runtime dispatcher (run(name, input)) handles all of them. See Tool use → Typed per-table tools.

Execute a { sql, params? } payload returned from a model. Internally calls db.query.

import { PerSQLError, RateLimitError } from "@persql/sdk";
try {
await db.query("SELECT 1");
} catch (e) {
if (e instanceof RateLimitError) {
await new Promise((r) => setTimeout(r, e.retryAfterSeconds * 1000));
} else if (e instanceof PerSQLError) {
console.error(e.status, e.message);
}
}
ClassExtendsFields
PerSQLErrorErrorstatus: number, detail?: SqlErrorDetail
RateLimitErrorPerSQLErrorretryAfterSeconds: number
ApprovalRequiredErrorPerSQLErrorapprovalToken, approvalUrl, hits, expiresAt

PerSQLError.detail carries a structured envelope on /v1/query and /v1/batch failures — branch on detail.kind (e.g. "unique_violation") instead of regexing the message. See Structured SQL errors.

ApprovalRequiredError is thrown when a write hits a require_approval rule. See Approval rules.

The SDK is fetch-based, has no Node-only dependencies, and runs on Cloudflare Workers, Vercel Edge, Deno, Bun, and the browser. In environments without a global fetch, pass one explicitly:

import { PerSQL } from "@persql/sdk";
import nodeFetch from "node-fetch";
const persql = new PerSQL({ token, fetch: nodeFetch as typeof fetch });