Integration Guide¶
Add document extraction to your application. Use self-hosted Koji in local dev, Koji Cloud in production — same API, different URL.
Architecture¶
Your App Koji
┌──────────┐ POST /extract ┌───────────┐
│ Your API │ ──────────────────▶ │ Koji API │
│ (Node, │ │ :9401 │
│ Python, │ ◀────────────────── │ │
│ etc.) │ { extracted } └───────────┘
└──────────┘
- Local dev: Koji runs in Docker alongside your app
- Production: Call
api.getkoji.devwith an API key
Local Development¶
Option A: Add to your docker-compose¶
Add Koji's services to your existing docker-compose.yaml:
services:
# ... your existing services ...
koji-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: koji
POSTGRES_USER: koji
POSTGRES_PASSWORD: koji
volumes:
- koji-db:/var/lib/postgresql/data
koji-parse:
image: ghcr.io/getkoji/parse:latest
ports: ["9411:9411"]
koji-api:
image: ghcr.io/getkoji/api:latest
ports: ["9401:9401"]
environment:
DATABASE_URL: postgres://koji:koji@koji-db:5432/koji
KOJI_PARSE_URL: http://koji-parse:9411
OPENAI_API_KEY: ${OPENAI_API_KEY}
depends_on:
- koji-db
- koji-parse
volumes:
koji-db:
Option B: Use the Koji CLI¶
If you prefer Koji managing its own stack:
# Install
uv tool install git+https://github.com/getkoji/koji.git
# Initialize with a template
koji init myproject --template insurance
cd myproject
# Start the cluster
export OPENAI_API_KEY=sk-...
koji start
# Dashboard at http://localhost:9400
# API at http://localhost:9401
HTTP API¶
POST /api/process — Parse + extract from a file¶
Upload a document and get structured data back in one call. The schema
field must be the full schema definition as JSON — not a slug or filename.
# Convert YAML schema to JSON and send with the file
curl -X POST http://localhost:9401/api/process \
-H "Authorization: Bearer koji_yourkey" \
-F "file=@document.pdf" \
-F "schema=$(python3 -c 'import yaml,json; print(json.dumps(yaml.safe_load(open(\"schemas/claim.yaml\"))))')"
If you omit schema, the endpoint returns just the parsed markdown (no extraction).
The schema field must be JSON, not a slug. Sending "schema": "claim" will not work — send the full {"name": "claim", "fields": {...}} object.
Response:
{
"filename": "document.pdf",
"pages": 3,
"parse_seconds": 2.1,
"model": "gpt-4o-mini",
"elapsed_ms": 1200,
"extracted": {
"vendor": "Acme Corp",
"total": 1500.00
},
"confidence": {
"vendor": "high",
"total": "high"
},
"confidence_scores": {
"vendor": 1.0,
"total": 1.0
}
}
POST /api/extract — Extract from pre-parsed markdown¶
If you've already parsed the document (or have text/markdown), skip the parse step:
curl -X POST http://localhost:9401/api/extract \
-H "Authorization: Bearer koji_yourkey" \
-H "Content-Type: application/json" \
-d '{
"markdown": "# Invoice\n\nVendor: Acme Corp\nTotal: $1,500.00",
"schema_def": {
"name": "invoice",
"fields": {
"vendor": {"type": "string", "required": true},
"total": {"type": "number", "required": true}
}
}
}'
POST /api/parse — Parse only (no extraction)¶
curl -X POST http://localhost:9401/api/parse \
-H "Authorization: Bearer koji_yourkey" \
-F "file=@document.pdf"
Returns parsed markdown, page count, and text map for provenance.
Uploading large files (presigned URL)¶
On Koji Cloud, direct file uploads are limited to 4.5 MB by the hosting platform. For larger documents, use the presigned URL flow — the client uploads directly to storage, bypassing the API server entirely.
Step 1: Get a presigned URL
curl -X POST https://console.getkoji.dev/api/upload/presign \
-H "Authorization: Bearer koji_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"filename": "large-document.pdf",
"contentType": "application/pdf",
"context": "corpus",
"schemaSlug": "claim_form"
}'
Response:
{
"uploadUrl": "https://storage.example.com/presigned-put-url...",
"storageKey": "corpus/tenant-id/schema-id/1718000000-large-document.pdf"
}
Step 2: Upload the file directly to storage
Step 3: Finalize the upload
curl -X POST https://console.getkoji.dev/api/upload/complete \
-H "Authorization: Bearer koji_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"storageKey": "corpus/tenant-id/schema-id/1718000000-large-document.pdf",
"filename": "large-document.pdf",
"context": "corpus",
"schemaSlug": "claim_form"
}'
The complete endpoint verifies the file exists, deduplicates by content hash, and creates the corpus entry. If the file was already uploaded (same hash), the duplicate is cleaned up and the existing entry is returned.
Programmatic example (Node.js):
async function uploadLargeFile(file: File, schemaSlug: string) {
// Step 1: Get presigned URL
const { uploadUrl, storageKey } = await fetch(`${KOJI_URL}/api/upload/presign`, {
method: "POST",
headers: {
"Authorization": `Bearer ${KOJI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
context: "corpus",
schemaSlug,
}),
}).then(r => r.json());
// Step 2: Upload directly to storage
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
// Step 3: Finalize
const entry = await fetch(`${KOJI_URL}/api/upload/complete`, {
method: "POST",
headers: {
"Authorization": `Bearer ${KOJI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ storageKey, filename: file.name, context: "corpus", schemaSlug }),
}).then(r => r.json());
return entry;
}
When to use presigned uploads: Always use this flow on Koji Cloud for files that may exceed 4.5 MB. For self-hosted deployments without a body size limit, the direct
POST /api/processflow works for any file size. The dashboard uses presigned uploads automatically.
Programmatic usage (Node.js / Python)¶
// Node.js — call /api/process with a file + schema
import fs from "fs";
import yaml from "yaml";
const schema = yaml.parse(fs.readFileSync("schemas/claim.yaml", "utf8"));
const form = new FormData();
form.append("file", new Blob([fs.readFileSync("document.pdf")]));
form.append("schema", JSON.stringify(schema));
const resp = await fetch(`${KOJI_URL}/api/process`, {
method: "POST",
headers: { Authorization: `Bearer ${KOJI_API_KEY}` },
body: form,
});
const { extracted } = await resp.json();
# Python — call /api/process with a file + schema
import httpx, yaml, json
schema = yaml.safe_load(open("schemas/claim.yaml"))
resp = httpx.post(
f"{KOJI_URL}/api/process",
headers={"Authorization": f"Bearer {KOJI_API_KEY}"},
files={"file": open("document.pdf", "rb")},
data={"schema": json.dumps(schema)},
)
extracted = resp.json()["extracted"]
Model configuration¶
The model used for extraction is configured in the dashboard under
Settings → Model Endpoints — not in koji.yaml. Add your OpenAI,
Anthropic, or other LLM API key there. The endpoint you configure in
the dashboard is what /api/process and /api/extract use.
Production: Koji Cloud¶
In production, point your API calls at Koji Cloud instead of localhost.
1. Get an API key¶
Or create one in the dashboard: Settings → API Keys.
2. Push schemas and pipelines¶
This scans for YAML files in schemas/, pipelines/, and classifiers/
subdirectories. Each file declares its type with a kind field.
3. Call the API¶
For small files (< 4.5 MB):
curl -X POST https://console.getkoji.dev/api/process \
-H "Authorization: Bearer koji_your_api_key" \
-F "file=@document.pdf" \
-F "schema=$(cat schema.json)"
For large files, use the presigned URL flow — see the HTTP API section above.
Environment switching¶
Use the same code in dev and prod — just change the URL:
const KOJI_URL = process.env.KOJI_URL ?? "http://localhost:9401";
const KOJI_API_KEY = process.env.KOJI_API_KEY; // only needed for Cloud
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (KOJI_API_KEY) {
headers["Authorization"] = `Bearer ${KOJI_API_KEY}`;
}
const response = await fetch(`${KOJI_URL}/extract`, {
method: "POST",
headers,
body: JSON.stringify({ markdown, schema_def: schema }),
});
const { extracted } = await response.json();
import os, httpx
KOJI_URL = os.environ.get("KOJI_URL", "http://localhost:9401")
KOJI_API_KEY = os.environ.get("KOJI_API_KEY")
headers = {}
if KOJI_API_KEY:
headers["Authorization"] = f"Bearer {KOJI_API_KEY}"
resp = httpx.post(
f"{KOJI_URL}/extract",
json={"markdown": markdown, "schema_def": schema},
headers=headers,
)
extracted = resp.json()["extracted"]
Schema Management¶
Schemas define what to extract. Store them in git, push to Koji:
# Create a schema
cat > schemas/claim.yaml << 'EOF'
name: claim
fields:
claimant_name:
type: string
required: true
date_of_loss:
type: date
required: true
amount_claimed:
type: number
EOF
# Create a pipeline that uses the schema
cat > pipelines/claims.yaml << 'EOF'
kind: pipeline
name: Claims Processing
slug: claims
schema: claim
EOF
# Push everything (schemas + pipelines)
koji push -d . -m "initial setup"
# Push to a local cluster (env var override)
KOJI_API_URL=http://localhost:9501 KOJI_API_KEY=koji_yourkey \
koji push -d . -m "initial setup"
# Pull latest from Koji Cloud
koji pull -o ./schemas
YAML kind field¶
Every YAML file must declare its type with a kind field.
koji push processes files with kind: schema, kind: pipeline, or
kind: classifier (untagged files are treated as schemas). Files with any
other kind (including koji.yaml, which uses kind: config) are skipped
and reported at the end.
# Schema — defines what to extract
kind: schema
name: claim
fields:
claimant_name:
type: string
required: true
# Pipeline — connects a schema to processing
kind: pipeline
name: Claims Processing
slug: claims
schema: claim # references schema by name
koji push reads kind and routes to the right API (kind: classifier
registers a standalone classifier via /api/classifiers, with the same
versions/promote/release lifecycle as a schema). Files with an unrecognized
kind are skipped and listed. Pipelines auto-link to the first active model
endpoint.
A pipeline file can also declare a full DAG with a steps: list
(classify routing, multiple extract steps, webhooks, …). Files with
steps: push their YAML to the server and land as DAG pipelines —
the routing you wrote is what runs:
kind: pipeline
name: Claims Router
slug: claims-router
steps:
- id: classify
type: classify
config:
labels:
- id: auto
- id: property
on:
auto: extract_auto
property: extract_property
_default: extract_auto
- id: extract_auto
type: extract
config:
schema: auto_claim
- id: extract_property
type: extract
config:
schema: property_claim
DAG pipelines don't need a top-level schema: reference — each extract
step names its own schema by slug and resolves it at run time.
A classify step can either define its classes inline (as above) or
reference a registered classifier by slug, so the pipeline reuses a
tested, independently-versioned classifier instead of carrying its own copy:
- id: classify
type: classify
config:
classifier: claim_type # a registered classifier (koji push kind: classifier)
on:
auto: extract_auto
property: extract_property
A referenced classifier runs through the exact same engine as
koji classify run / POST /api/classify, so the pipeline routes a document
the same way the standalone classifier labels it. By default it resolves to the
classifier's current released version at run time; pin a specific version with
classifier_version to hold it steady through a staged rollout:
- id: classify
type: classify
config:
classifier: claim_type
classifier_version: v0.0.3 # optional — omit to follow the live release
A pin that doesn't resolve fails loud (the step returns unknown) instead of
silently running a different version. Push the classifiers before (or alongside)
the pipelines that reference them — koji push handles the ordering for you.
Authentication¶
Koji Cloud: Run koji login to create a profile, or set env vars:
Local cluster: After setup at http://localhost:9500/setup, create an API key in Settings → API Keys, then:
All CLI commands (push, pull, bench) respect KOJI_API_URL and KOJI_API_KEY env vars. These override the active CLI profile.
Embedding the PDF Viewer¶
Koji includes a standalone PDF viewer that you can embed in your application via an iframe. It renders the original document with provenance highlights showing where each extracted field was found.
Quick start¶
<iframe
src="https://console.getkoji.dev/embed/viewer?job=JOB_SLUG&doc=DOC_ID&token=PREVIEW_TOKEN"
style="width: 100%; height: 600px; border: none;"
></iframe>
Getting the embed parameters¶
When you fetch a document's detail via the API, the response includes a signed preview token:
curl https://api.getkoji.dev/api/jobs/JOB_SLUG/documents/DOC_ID \
-H "Authorization: Bearer koji_yourkey" \
-H "x-koji-tenant: your-tenant-slug"
The response includes documentPreviewUrl and documentToken. Use these to construct the embed URL:
const detail = await fetch(`${KOJI_URL}/api/jobs/${jobSlug}/documents/${docId}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"x-koji-tenant": tenantSlug,
},
}).then((r) => r.json());
const embedUrl = new URL("/embed/viewer", KOJI_DASHBOARD_URL);
embedUrl.searchParams.set("job", jobSlug);
embedUrl.searchParams.set("doc", docId);
embedUrl.searchParams.set("token", detail.documentToken);
// Optional: jump to a specific field
embedUrl.searchParams.set("field", "vendor_name");
document.getElementById("viewer").src = embedUrl.toString();
Two modes¶
| Mode | When to use | Query params |
|---|---|---|
| Document mode | You have a Koji job/document and want the API to provide the PDF and highlights | job, doc, token |
| URL mode | You have your own PDF URL and want to provide highlights directly | url, highlights (base64 JSON) |
Document mode fetches the PDF and provenance highlights from the Koji API automatically. The HMAC token grants 1-hour access without requiring the iframe to have a session cookie.
URL mode lets you bring your own PDF — no Koji API calls from the iframe:
<iframe src="https://console.getkoji.dev/embed/viewer?url=https://example.com/doc.pdf&highlights=BASE64_JSON"></iframe>
The highlights param is a base64-encoded JSON array of highlight objects:
const highlights = [
// bbox coordinates are NORMALIZED fractions of the page (0–1), not pixels.
{ field: "vendor", label: "Vendor", value: "Acme Co", page: 1, bbox: { x: 0.10, y: 0.20, w: 0.30, h: 0.03 } },
{ field: "total", label: "Total", value: "$6,000", page: 1, bbox: { x: 0.10, y: 0.25, w: 0.20, h: 0.03 } },
];
// UTF-8-safe base64 (labels/values may contain non-ASCII — em dashes,
// accents, currency symbols). Plain btoa(JSON.stringify(...)) throws on those.
const json = JSON.stringify(highlights);
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(json)));
// → use as ?highlights=<encoded>
The viewer decodes the param as UTF-8, so non-ASCII labels/values round-trip
correctly. (For ASCII-only payloads, plain btoa(JSON.stringify(highlights))
also works.)
Highlight object (BBoxHighlight)¶
| Field | Type | Required | Notes |
|---|---|---|---|
field |
string | yes | Stable match key — what koji:setActiveField / koji:fieldClicked resolve against. May be opaque (e.g. a record id). |
page |
number | yes | 1-indexed page. |
bbox |
{ x, y, w, h } |
one of bbox/words |
Normalized fractions of the page, 0–1 (top-left origin). |
words |
{ text, page, x, y, w, h }[] |
one of bbox/words |
Per-word boxes (also normalized 0–1); use for precise multi-word highlights. |
value |
string | no | Extracted value; the field picker shows it as name: value. |
label |
string | no | Human-readable name for the field picker. The picker renders label ?? field, so you can keep field opaque without leaking it into the UI. |
reasoning |
string | no | Tooltip text shown on hover. |
resolution |
string | no | How the location was resolved — the provenance resolution rung: offset/chunk/anchored (exact) or fuzzy (best guess). Drives the viewer's exact-vs-approximate styling (see below). Highlights fetched from the API (document mode) carry this automatically; omit it for host-supplied highlights and the box renders as exact. |
Exact vs. best-guess highlights. The viewer styles each box by its
resolution: an exact locate (offset/chunk/anchored, or no resolution)
renders as a solid box, while a best guess (fuzzy) renders with a dashed,
muted border and a "location is approximate" note on hover — so users can tell a
precise highlight from an approximate one at a glance.
Messaging schema (postMessage)¶
Control runs over window.postMessage. Every message is a plain object with a
type string prefixed koji:. The viewer ignores anything without that prefix.
Envelope is flat. Payload fields sit at the top level of the message object,
not nested under a payload key — e.g. { type: "koji:goToPage", page: 3 },
{ type: "koji:setActiveField", field: "vendor" },
{ type: "koji:setHighlights", highlights: [...] }.
Inbound — parent → viewer (iframe.contentWindow.postMessage(msg, viewerOrigin)):
type |
Payload | Effect |
|---|---|---|
koji:setActiveField |
{ field: string \| null } |
Highlight a field and scroll/page to it. null clears the selection. |
koji:setHighlights |
{ highlights: BBoxHighlight[] } |
Replace all highlights (e.g. after re-extraction). |
koji:goToPage |
{ page: number } |
Jump to a 1-based page (clamped to the document). |
koji:setToken |
{ token: string } |
Swap in a fresh documentToken without reloading the iframe — see Token refresh. |
koji:setTheme |
{ theme: { activeColor?: string; inactiveColor?: string } } |
Recolor the highlight boxes (any CSS color; pass rgba()/hsla() for translucency). |
koji:setViewMode |
{ mode?: "paginated" \| "scroll"; overflow?: "auto" \| "scroll" \| "hidden" } |
Switch the layout at runtime — see Layout. Both fields optional; unknown values are ignored. |
koji:setSelectionMode |
{ field: string \| null } |
Arm region selection on behalf of a field (null disarms). Requires ?tools=select — ignored (with a console warning) otherwise. See Region selection. |
How fields resolve (important)¶
koji:setActiveField, koji:goToPage, and koji:fieldClicked all operate over
the viewer's current highlights array:
koji:setActiveField(field)only resolves afieldthat exists in the current highlights. It finds the matching highlight, navigates to its page, scrolls to[data-highlight-field="<field>"], and emphasizes that box. Afieldthat isn't in the current highlights silently no-ops — nothing scrolls, no error.- In Document mode the viewer auto-loads native highlights from
GET /api/jobs/{job}/documents/{doc}/embed-data, keyed by Koji's own field names (e.g.total_premium,coverages.0). SosetActiveFieldworks out-of-the-box with those keys — but not with your app's internal ids. koji:setHighlightsreplaces the entire set. If you want to drive the viewer with your own keys, push them viasetHighlights(give each alabelfor a readable picker — see Highlight object). Note this races the async native load in Document mode: sendsetHighlightsafter you receivekoji:ready, or use URL mode (no native load) if you only ever want your own highlights.
Outbound — viewer → parent (your window.addEventListener("message", …)):
type |
Payload | When |
|---|---|---|
koji:ready |
{ pageCount: number } |
The PDF has loaded and the viewer is ready to accept commands. |
koji:fieldClicked |
{ field: string; page: number } |
The user clicked a highlight box in the PDF. |
koji:pageChanged |
{ page: number } |
The most-visible page changed (1-indexed). Fires as the user scrolls in mode=scroll and on page navigation in mode=paginated. |
koji:visibleField |
{ field: string \| null; page: number } |
The highlighted field whose box is most prominently in view changed. field uses the same key space as koji:setActiveField (e.g. total_premium, coverages.0); null when no highlighted field is in view. |
koji:regionSelected |
{ field: string \| null; page: number; bbox; text: string \| null; words } |
The user selected a region (?tools=select), already resolved to the text underneath. See Region selection. |
koji:pageChanged and koji:visibleField let a parent keep its own UI (e.g. a
field list) in sync with the viewer's scroll — the cross-origin iframe boundary
otherwise hides scroll position from the parent. They are debounced (~120ms)
and deduped (emitted only when the value actually changes), and only fire
after koji:ready. They emit for both user scrolling and programmatic
navigation (koji:goToPage / koji:setActiveField); since they're deduped by
value, a parent that also dedupes won't see feedback loops. If no parentOrigin
is known, they are not emitted (they never post to "*").
Always pass a real
targetOrigin— never"*". When you post to the viewer, the second arg is the viewer's origin (e.g.https://console.getkoji.dev). For the viewer to post back to you, tell it your origin with?parentOrigin=https://your-app.comon the iframesrc. If you omit it, the viewer falls back to the embedding page's origin (document.referrer) and only posts to"*"as a last resort (with a console warning). Outbound payloads contain no document bytes, but scoping the origin is still the correct posture.
const iframe = document.getElementById("viewer") as HTMLIFrameElement;
const VIEWER_ORIGIN = "https://console.getkoji.dev";
// Listen for outbound events from the viewer
window.addEventListener("message", (e) => {
if (e.origin !== VIEWER_ORIGIN) return; // verify the sender
const msg = e.data;
if (msg?.type === "koji:ready") {
console.log(`viewer ready, ${msg.pageCount} pages`);
}
if (msg?.type === "koji:fieldClicked") {
// sync selection back into your own UI
selectFieldInMyApp(msg.field);
}
if (msg?.type === "koji:pageChanged") {
updatePageIndicator(msg.page); // e.g. "Page 3 of 12"
}
if (msg?.type === "koji:visibleField") {
// highlight the field row your user is currently scrolled to (or clear it)
setActiveRowInMyApp(msg.field); // string key, or null
}
});
// Drive the viewer from your UI
function onFieldClick(fieldName: string) {
iframe.contentWindow!.postMessage(
{ type: "koji:setActiveField", field: fieldName },
VIEWER_ORIGIN, // not "*"
);
}
// Navigate, recolor
iframe.contentWindow!.postMessage({ type: "koji:goToPage", page: 3 }, VIEWER_ORIGIN);
iframe.contentWindow!.postMessage(
{ type: "koji:setTheme", theme: { activeColor: "rgba(220,38,38,0.4)", inactiveColor: "rgba(0,0,0,0.12)" } },
VIEWER_ORIGIN,
);
Theming¶
Match the highlight colors to your host UI either at load time via query params
or at runtime via koji:setTheme:
<iframe src="https://console.getkoji.dev/embed/viewer?job=JOB&doc=DOC&token=TOKEN&activeColor=rgba(220,38,38,0.4)&inactiveColor=rgba(0,0,0,0.12)"></iframe>
activeColor styles the selected highlight; inactiveColor styles the rest.
Both accept any CSS color — use rgba()/hsla() for translucent fills so the
underlying text stays readable.
Layout: scroll vs paginated¶
The viewer defaults to paginated layout — one page at a time with ‹ / ›
arrow navigation. To render every page stacked in a continuous scrollable
column instead, set mode=scroll. Control the scrollbar with overflow
(auto default, scroll always-visible, hidden none). Set them at load time:
<iframe src="https://console.getkoji.dev/embed/viewer?job=JOB&doc=DOC&token=TOKEN&mode=scroll&overflow=auto"></iframe>
…or switch at runtime (both fields optional):
| Param | Values | Default | Meaning |
|---|---|---|---|
mode |
paginated, scroll |
paginated |
Arrow-paged single page vs. all pages stacked. |
overflow |
auto, scroll, hidden |
auto |
Scrollbar behavior of the viewer container. |
koji:goToPage works in both layouts — it flips the page in paginated mode and
scrolls the page into view in scroll mode. Unknown values fall back to the
defaults.
Region selection (?tools=select)¶
Let reviewers point at where the correct value lives instead of retyping it. With the tool enabled, the user drags a rectangle on the document, the viewer resolves the region to the text underneath (server-side, snapped to exact word boxes), and you receive the derived value — ready to feed into your correction flow. Your app never touches PDF geometry.
Optional tools are off by default and enabled per-embed with the tools
query param (comma-separated list; select is the only tool today):
<iframe src="https://console.getkoji.dev/embed/viewer?job=JOB&doc=DOC&token=TOKEN&tools=select"></iframe>
Two ways to enter selection mode:
- Host-driven — post
{ type: "koji:setSelectionMode", field: "total_amount" }when your user clicks "point at it" next to a field in your UI. Thefieldis an opaque key echoed back on the result so you can route it;nulldisarms and clears the on-page echo. - Self-serve — with the tool enabled, the viewer shows a crosshair toggle
in its toolbar. Selections made this way emit with
field: null.
While armed the cursor is a crosshair and the user drags a marquee; the selection snaps to the words underneath (a word counts when at least half of it is covered), the snapped words flash on the page, and the viewer emits:
{
type: "koji:regionSelected",
field: "total_amount", // echoes setSelectionMode's field (null for the toolbar toggle)
page: 1,
bbox: { x: 0.62, y: 0.81, w: 0.18, h: 0.03 }, // union of the matched words, normalized 0–1
text: "$6,000.00", // the words in reading order (lines joined with \n)
words: [ { text: "$6,000.00", page: 1, x: 0.62, y: 0.81, w: 0.18, h: 0.03 } ],
}
text: null (with empty words) means nothing was found under the region —
no positional text data for the document, or the user selected whitespace.
Treat that as "fall back to manual input", not as an error. Since the text
comes from the document's OCR/text layer, always show it to the user for
confirmation before committing a correction.
Resolution happens in Document mode via
POST .../resolve-region
using the embed's own token — no extra auth or API calls on your side. In
URL mode there is no document to resolve against: the message carries the
raw drag rectangle with text: null, and resolving is up to you.
// Host side: arm selection for a field, receive the corrected value
function pointAtField(fieldKey: string) {
iframe.contentWindow!.postMessage(
{ type: "koji:setSelectionMode", field: fieldKey },
VIEWER_ORIGIN,
);
}
window.addEventListener("message", (e) => {
if (e.origin !== VIEWER_ORIGIN) return;
const msg = e.data;
if (msg?.type === "koji:regionSelected") {
// disarm now that we have a pick
iframe.contentWindow!.postMessage({ type: "koji:setSelectionMode", field: null }, VIEWER_ORIGIN);
if (msg.text != null) {
showConfirmChip(msg.field, msg.text, msg); // let the user confirm/edit, then save
} else {
focusManualInput(msg.field); // nothing under the selection
}
}
});
Saving the correction. Once your user confirms, write it back from your backend with your API key (the embed's preview token is read-only by design — the iframe can never write):
// Your backend, after the user confirms the value:
await fetch(`${KOJI_API}/api/jobs/${jobSlug}/documents/${docId}/corrections`, {
method: "POST",
headers: { "Authorization": `Bearer ${KOJI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
corrections: [{
field: msg.field,
value: confirmedValue,
// pass the selection through so the correction keeps its location
provenance: { page: msg.page, bbox: msg.bbox, words: msg.words, chunk: msg.text },
}],
}),
});
This records an audited correction (reason: "manual" review item), updates
the extraction, stores the anchored source highlight, and fires a
document.corrected webhook
so every consumer of the document stays in sync. See the
corrections endpoint reference.
Field picker¶
In Document mode, the viewer shows a built-in dropdown in the toolbar listing
each highlighted field. Each row shows label ?? field, optionally suffixed
with : value — so if you push highlights with opaque field keys via
koji:setHighlights, give each a human-readable label and the dropdown stays
readable (the field key is still what gets selected). Selecting an entry jumps
to that field's highlight — flipping the page in paginated mode or scrolling it
into view in scroll mode — and marks it active. The dropdown stays in sync with
koji:setActiveField and with clicks on the highlights themselves
(koji:fieldClicked).
For Document-mode native highlights, the value (and the field name shown when
there's no label) come from the
/embed-data
response. Hide the dropdown when your host UI already provides field navigation:
<iframe src="https://console.getkoji.dev/embed/viewer?job=JOB&doc=DOC&token=TOKEN&fieldPicker=off"></iframe>
URL mode (
?url=…) can supply the samevalueon each highlight in the base64highlightspayload to populate the picker.
Authentication & static assets¶
The embed viewer is cookieless and cross-origin by design — it never relies
on a session cookie, so it works with third-party cookies fully blocked (Safari
ITP, Chrome). Document-mode auth is the HMAC documentToken:
- Tokens are time-limited (1 hour) and path-scoped — signed against
/api/jobs/{slug}/documents/{docId}, so a token for one document cannot access another, and the document PDF + provenance are inaccessible without a valid, unexpired, correctly-scoped token. - The viewer's own static assets are served unauthenticated. The PDF.js
worker (
/pdf.worker.mjs), JS/wasm chunks, fonts, and source maps are generic and non-sensitive, so they return200to an unauthenticated, cookieless request and never redirect to/sign-in. Only the document bytes stay gated by the token. (Previously the worker was auth-gated and 302'd to sign-in, so the PDF never rendered in a cross-origin iframe — that's fixed.) X-Frame-Optionsis removed andContent-Security-Policy: frame-ancestorspermits external origins for/embed/*. By default any origin may embed (frame-ancestors *); self-hosters can restrict it to an allowlist via theKOJI_EMBED_FRAME_ANCESTORSenv var (a space-separated CSP source list, e.g.https://app.acme.com https://*.acme.com).- No CORS configuration needed — the iframe loads the viewer page directly, and the viewer fetches the PDF from its own (same) origin.
Token refresh for long sessions¶
The documentToken expires after 1 hour. For multi-hour review sessions, mint a
fresh token server-side (re-GET /api/jobs/{slug}/documents/{docId}) and push it
in with koji:setToken — do not reload the iframe. The viewer swaps the
token on its preview URL so subsequent fetches stay authorized, while the current
page and selection are preserved:
// Every ~50 minutes, before the current token expires:
const { documentToken } = await fetchFreshTokenFromYourServer(jobSlug, docId);
iframe.contentWindow!.postMessage(
{ type: "koji:setToken", token: documentToken },
VIEWER_ORIGIN,
);
Self-hosted¶
If you're running Koji locally or self-hosted, replace console.getkoji.dev with your dashboard URL (e.g. http://localhost:9400):
Docker Images¶
All images are published to GitHub Container Registry:
| Image | Purpose | Default Port |
|---|---|---|
ghcr.io/getkoji/api |
API server + dashboard | 9401 |
ghcr.io/getkoji/parse |
Document parsing (PDF, Word, images) | 9411 |
ghcr.io/getkoji/extract |
LLM extraction engine | 9412 |
Pull with: