@okrapdf/sdk
v0.17.0
Published
okraPDF — upload a PDF, get an API. Runtime client, React hooks, and CLI.
Maintainers
Readme
@okrapdf/sdk
Upload a PDF, get an OpenAI-compatible endpoint.
npm install @okrapdf/sdkJust want the okra command? npm install -g @okrapdf/cli (this package is the library it wraps; the legacy unscoped okrapdf package is deprecated).
Get your API key at app.okrapdf.com/settings.
Quick Start
import { OkraClient } from '@okrapdf/sdk';
const okra = new OkraClient({ apiKey: process.env.OKRA_API_KEY });
const session = await okra.sessions.create('./invoice.pdf');
// Every document gets its own chat/completions URL
console.log(session.modelEndpoint);That prints a URL like:
https://api.okrapdf.com/v1/documents/doc-441a8a0be0e94914b982This is a full OpenAI-compatible base URL. Plug it into any client.
Choose Your Entry Point
| I want to... | Start here |
| -------------------------------- | --------------------------------------------------------------------------- |
| Use TypeScript/JS | okra.sessions.create() + session.prompt() — SDK Methods |
| Use the CLI | okra auth login + okra upload — CLI |
| Use Claude Code or AI agents | MCP server — Setup guide |
| Use any LLM client | OpenAI-compatible endpoint — OpenAI SDK |
| Build a React app | @okrapdf/sdk/react hooks — Sub-path exports |
| Query across documents | Collections fan-out — Collections |
What You Get
Upload a PDF and okraPDF gives you predictable URLs for everything:
Document: doc-441a8a0be0e94914b982
Completion: https://api.okrapdf.com/document/doc-441a8a0be0e94914b982/chat/completions
Status: https://api.okrapdf.com/document/doc-441a8a0be0e94914b982/status
Pages: https://api.okrapdf.com/document/doc-441a8a0be0e94914b982/pages
Entities: https://api.okrapdf.com/document/doc-441a8a0be0e94914b982/nodes
Download: https://api.okrapdf.com/document/doc-441a8a0be0e94914b982/download
Page images:
pg 1: https://api.okrapdf.com/v1/documents/doc-441a8a0be0e94914b982/pg_1.png
resized: https://api.okrapdf.com/v1/documents/doc-441a8a0be0e94914b982/w_200,h_300/pg_1.png
shimmer: https://api.okrapdf.com/v1/documents/doc-441a8a0be0e94914b982/d_shimmer/pg_1.pngAll URLs are deterministic. Build them from the document ID without calling the API first.
Use with OpenAI SDK
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OKRA_API_KEY,
baseURL: session.modelEndpoint, // https://api.okrapdf.com/v1/documents/doc-...
});
const res = await openai.chat.completions.create({
model: 'okra',
messages: [{ role: 'user', content: 'What form is this?' }],
});
console.log(res.choices[0].message.content);
// → "This is Form W-9 (Request for Taxpayer Identification Number and
// Certification), used by entities to collect a taxpayer's TIN..."Use with AI SDK
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { generateText } from 'ai';
const provider = createOpenAICompatible({
name: 'okra',
apiKey: process.env.OKRA_API_KEY,
baseURL: session.modelEndpoint,
});
const { text } = await generateText({
model: provider('okra'),
prompt: 'Summarize this document in 3 bullet points',
});Use with curl
# Upload
curl -X POST https://api.okrapdf.com/document/doc-my-w9/upload-url \
-H "Authorization: Bearer $OKRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.irs.gov/pub/irs-pdf/fw9.pdf"}'
# Ask a question
curl https://api.okrapdf.com/document/doc-my-w9/chat/completions \
-H "Authorization: Bearer $OKRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "List all parts of this form."}]}'Response:
{
"id": "chatcmpl-18g5qhmmrm",
"object": "chat.completion",
"model": "accounts/fireworks/models/kimi-k2p5",
"choices": [
{
"message": {
"role": "assistant",
"content": "Based on the Form W-9 document, there are two numbered parts:\n\n| Part | Title |\n|------|-------|\n| Part I | Taxpayer Identification Number (TIN) |\n| Part II | Certification |"
},
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 227, "completion_tokens": 404, "total_tokens": 631 }
}SDK Methods
The SDK wraps all of this so you don't need a separate client:
// Ask a question (non-streaming)
const { answer } = await session.prompt('What is the total amount due?');
// Stream
for await (const event of session.stream('Summarize this document')) {
if (event.type === 'text_delta') process.stdout.write(event.text);
}
// Structured output with Zod
import { z } from 'zod';
const Invoice = z.object({
vendor: z.string(),
total: z.number(),
lineItems: z.array(
z.object({
description: z.string(),
amount: z.number(),
}),
),
});
const { data } = await session.prompt('Extract the invoice', { schema: Invoice });
// data: { vendor: "Acme Corp", total: 1250.00, lineItems: [...] }Declarative Components
A Component declares the desired structured result. Reusable field Components keep their own schema, grader, assertion, and instructions; the containing document Component only composes them.
/** @jsxImportSource @okrapdf/component */
import { z } from 'zod';
import { Assertion, Component, Field, Grader, StructuredOutput } from '@okrapdf/component';
function TotalField() {
return (
<Field name="total" schema={z.string().nullable()}>
<Grader name="Total Amount Accuracy" type="string_check" operation="eq" />
<Assertion evaluator="receipt.total-valid" />
</Field>
);
}
export default (
<Component id="receipt" name="Ideal receipt" dataset={{ provider: 'local', id: 'receipts' }}>
<StructuredOutput name="receipt_details">
<TotalField />
</StructuredOutput>
</Component>
);The compiler resolves the local requirements to $.total, emits a strict JSON
Schema and canonical workflow definition, and leaves evaluator implementation
to Effect Layers in the runtime. The same declaration works through the SDK or
CLI against hosted and self-hosted servers:
await okra.components.run(receipt, { inputs: { document_id: 'doc-123' } });okra components build ./receipt.tsx
okra components run ./receipt.tsx --document doc-123Pages & Entities
const pages = await session.pages(); // { pageCount: 6, pages: [...] }
const { nodes } = await session.entities(); // extracted text, tables, etc.
const { nodes } = await session.entities({ type: 'table' });Upload
Accepts file paths, URLs, Blob, ArrayBuffer, or Uint8Array:
// URL
const session = await okra.sessions.create('https://example.com/report.pdf');
// Bytes
const session = await okra.sessions.create(pdfBytes, {
upload: { fileName: 'report.pdf' },
});
// Attach to existing document (no upload, no wait)
const session = okra.sessions.from('doc-441a8a0be0e94914b982');If you want a passive PDF asset upload instead of an immediate document workflow, use files.upload():
const file = await okra.files.upload('./report.pdf');
console.log(file.id); // doc-...
console.log(file.urls.bytes); // direct PDF bytes URL
console.log(file.workflow_bound); // falseokra.sessions.create() / okra.upload() still mean "upload and start document processing."
okra.files.upload() means "store this PDF as a first-class file asset and let me decide later what workflow to run."
An upload can report live processing progress after the upload request has completed:
const controller = new AbortController();
const session = await okra.upload(pdfBytes, {
signal: controller.signal,
onProgress: (event) => console.log(event.type, event.data),
});The progress subscription closes on terminal progress, upload failure, or signal abort.
sessions.create() also releases its upload progress subscription when its wait finishes.
Calling controller.abort() stops this client's upload and progress subscription; server work
that has already been accepted may continue.
For explicit document-event iteration, subscribe before opening the connection:
const events = okra.events(session.id, { bufferSize: 1024 });
const iterator = events[Symbol.asyncIterator]();
events.connect();
try {
for await (const event of iterator) {
console.log(event.type, event.data);
if (event.type === 'document.ready') break;
}
} finally {
events.close();
}Callbacks receive live events without retaining an iterator backlog. Iteration starts at
iterator creation and shares one cursor. Its unread buffer defaults to 1024 events; overflow
closes the connection and rejects the iterator explicitly. A remote close drains accepted
events. Explicit close, signal abort, or iterator return discards unread events and releases
pending reads. Calling connect() repeatedly keeps the same connection.
Create a new event stream after it closes to subscribe again.
session.wait({ timeoutMs, pollIntervalMs }) applies its deadline to status requests,
response-body reads and polling sleeps. With realtime: true, a timed-out event wait
closes its subscription before starting a fresh polling window. After a ready event,
the final status request uses the same deadline; if it fails or times out, the ready
event supplies the result. Caller cancellation releases local resources and rejects
with TIMEOUT / status 499.
Parse Jobs & Polling
Use okra.parse() for a passive-file parse job, or create a document reparse job
with okra.createJob({ type: 'document.parse', document_id: 'doc-...' }).
The same job envelope is returned from POST /v1/parse, POST /v1/jobs,
GET /v1/jobs/{id}, and okra jobs wait:
const job = await okra.parse({ fileId: file.id, parser: 'textlayer' });
console.log(job.terminal); // false until completed/failed/cancelled
console.log(job.progress?.percent); // best-known 0-100 progress
console.log(job.next_poll_after_ms); // n8n-friendly poll delay
console.log(job.retryable, job.error_code, job.user_message);
console.log(job.cost_usd); // real vendor usage at completion when availablePass signal to okra.parse() to cancel input preparation or the client request.
Cancellation stops later submission after a pending Blob or local-file read,
releases response readers, and discards responses that arrive after cancellation.
A job already accepted by the server continues; its job ID remains the handle for
observing or cancelling server processing.
Parse jobs expose live page/chunk counters while work is running:
pages_completed, pages_failed, pages_running, pages_pending,
pages_total, chunks_completed, and chunks_total. Empty parser output now
fails with error_code: "empty_result" instead of reporting a completed job
with no page coverage.
Document Wait-For Subscriptions
okra documents wait-for <docId> --match "<term>" creates a document
subscription, then long-polls until matching nodes are ingested:
okra documents wait-for doc-abc123 --match "table of contents" --timeout 120
okra documents wait-for doc-abc123 --match "Revenue" --literalThe CLI prints a structured envelope with result.matched_nodes[] entries:
node_id, page, value_excerpt, optional bbox, and optional bbox_source.
It also includes next_actions such as reading the matched page. The underlying
API is POST /v1/documents/{id}/subscriptions followed by
GET /v1/documents/{id}/subscriptions/{subscription_id}/wait?timeout=...;
wait responses return status: "active" | "matched" | "timeout", matched,
and matches[].
Deterministic URLs
Build page image and export URLs from a document ID — no API call needed:
import { doc } from 'okrapdf/doc';
const d = doc('doc-441a8a0be0e94914b982');
d.pages(1).image(); // .../pg_1.png
d.pages(1).image({ w: 200, h: 300 }); // .../w_200,h_300/pg_1.png
d.export('markdown'); // .../export.mdCollections — Fan-Out Query to CSV
Ask the same question across every document in a collection. Each doc answers independently in parallel, results stream back as NDJSON.
import { OkraClient } from '@okrapdf/sdk';
import { z } from 'zod';
import { writeFileSync } from 'fs';
const okra = new OkraClient({ apiKey: process.env.OKRA_API_KEY });
// Fan-out: ask every doc in the collection the same question
const stream = okra.collections.query(
'col-40da068481cf4f248853507cba6be611',
'Who are the top 3 people mentioned in this document?',
);
// Gather all results
const result = await stream.gather();
// Write CSV
const header = 'doc_id,doc_name,answer,cost_usd';
const rows = [...result.answers.values()].map(
(a) => `"${a.docId}","${a.answer.slice(0, 200)}",${a.costUsd}`,
);
writeFileSync('results.csv', [header, ...rows].join('\n'));
console.log(`${result.completed} docs, $${result.totalCostUsd.toFixed(4)} total`);Real output from a 10-K earnings collection:
doc_id,file_name,answer,cost_usd
"doc-9a3f21...","NVDA-10K-2025.pdf","Revenue: $130.5B, Net Income: $72.9B, YoY Growth: 114%",0.0048
"doc-b7e810...","AAPL-10K-2025.pdf","Revenue: $391.0B, Net Income: $101.2B, YoY Growth: 5%",0.0039
"doc-c4d562...","MSFT-10K-2025.pdf","Revenue: $254.2B, Net Income: $97.1B, YoY Growth: 16%",0.0051Experimental: structured collection fan-out is still available via a schema, but it is not part of the stable v0.14 surface:
const FinancialReport = z.object({
company: z.string(),
revenue: z.number(),
netIncome: z.number(),
quarter: z.string(),
});
const stream = okra.collections.query('col-financials', 'Extract the financial summary', {
schema: FinancialReport,
});
const result = await stream.gather();
for (const [docId, answer] of result.answers) {
console.log(answer.data); // { company: "NVIDIA", revenue: 35082, ... }
}Or stream per-doc events in real time:
for await (const event of stream) {
if (event.type === 'result') {
console.log(`${event.doc_id}: ${event.answer.slice(0, 80)}...`);
}
}Sub-path Exports
| Import | Use |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| @okrapdf/sdk | OkraClient, types, errors |
| @okrapdf/sdk/doc | doc() URL builder |
| @okrapdf/sdk/browser | Browser-safe client (no Node.js deps) |
| @okrapdf/sdk/worker | Cloudflare Worker adapter |
| @okrapdf/sdk/react | React hooks (useSession, usePages) |
| @okrapdf/sdk/content-types | Typed extraction contracts — getContentType, listContentTypes, manifests (dependency-light: no client/CLI bundle) |
CLI
npm install -g @okrapdf/cli # the `okra` binary lives in @okrapdf/cli, a thin wrapper over this SDK
okra auth login
okra upload ./invoice.pdf
okra context ask "What is the total?" --source-id doc-abc123 # cited answer
okra extract ./invoice.pdf --content-type invoice # typed + grounded; or --schema ./schema.json --cite
okra invoice extract ./invoice.pdf # the same, as a first-class noun
okra collections query earnings "What changed quarter over quarter?" -o earnings.csvUse npx @okrapdf/sdk ... for one-off runs if you do not want a global install.
Agent Skill Install
For skill-aware coding agents, the intended distribution form is:
npx skills add okrapdf/okra-agentPublishing the public okrapdf/okra-agent skill repo is a follow-up release step. The form that works today from an installed CLI package is:
npm install @okrapdf/sdk
npx skills add ./node_modules/@okrapdf/sdk/skills/okra-agentIf your install path differs, use ./node_modules/<pkg>/skills/okra-agent, where <pkg> is the installed okra package directory.
To point the same CLI commands at a self-hosted or Railway instance, create and activate a profile:
okra profile add local --base-url https://my-okra.up.railway.app
okra profile use local
okra upload ./invoice.pdf
okra parse doc-abc123 --model gemini-3-flash --prompt layout-bbox-gemini-multipage@1
okra audit doc-abc123 --standard wcag
okra redact doc-abc123 --model local
okra open doc-abc123 --view reviewSelf-host runtime bundles can be checked locally without starting a server:
okra self-host validate ./examples/self-host-runtime
okra self-host plan ./examples/self-host-runtime --target railway
okra self-host railway ./examples/self-host-runtime > railway.json
okra self-host env ./examples/self-host-runtime
okra self-host compose ./examples/self-host-runtime --mode stack > docker-compose.yml
okra self-host compose ./examples/self-host-runtime --mode networks > docker-compose.networks.yml
okra self-host smoke https://my-okra.up.railway.app --workflow bothStart the zero-config local server and its attached browser workbench through the CLI package:
npx @okrapdf/cli@latest web
npx @okrapdf/cli@latest serve # server-first; do not open a browser
OKRA_SERVER_PASSWORD=secret npx @okrapdf/cli@latest web --host 0.0.0.0Both commands expose okra.client.v1 over HTTP+SSE, persist local state under
~/.okra/serve, and serve the same document-first workbench at /. Opening a
PDF never starts parsing. A validated runtime bundle remains optional:
okra serve ./examples/self-host-runtime --port 8787CLI diagnostics, completions, and exits
okra doctor
okra doctor --json
okra completions zsh > ~/.zfunc/_okra
okra completions bash > ~/.okra-completions.bashokra doctor checks the local Node runtime, auth/config, API reachability,
installed CLI version, and self-host environment variables without printing
secrets. In headless mode or with --json, failures use a stable envelope:
ok, command, error, message, hint, retryable, numeric HTTP-style
code, process exit_code, and next_actions.
Exit codes: 0 success, 1 client/auth/usage error, 2 server or network
error.
For Docker/Railway, the example image runs the same command against
/app/self-host-runtime and exposes /health, /v1/self-host/status,
/v1/workflows/catalog, /v1/capabilities, SDK-compatible document routes
under /document/:id/*, shared graph routes under /v1/documents/:id/*,
/v1/sources/n8n, and /v1/workflows. Starter uploads and workflow runs write
OkraDocumentGraph artifacts under OKRA_DATA_DIR; workflow runs also persist
one inspectable capability_run per declared recipe step.
Set OKRA_API_KEY on public self-host deployments to require bearer auth on
uploads, URL ingestion, n8n source intake, and workflow execution.
The CLI focuses on the core workflows: auth, upload, extract, chat/ask,
list/delete, render, collection query, endpoint profiles, and self-host bundle
validation/planning/runtime launch. Advanced
inspection commands (find, search, status) and the facet/lens
deployment commands remain available but are hidden from default help.
Experimental structured collection extraction is available via
okra collection extract ... and okra collection query ... --schema ....
License
MIT
