Skip to main content

Project API

Everything on a project's dashboard is an HTTP call your server can make with the project key. Nothing here needs a browser, a session or a human, so a project's configuration can live in your repository and be applied by a deploy rather than reconstructed by clicking.

export OBERIK_PROJECT_KEY="pk_…"
export OBERIK_PROJECT_ID="7f3c…" # a UUID
CP=https://oberik.com

Which client

There are two, and knowing the feature does not tell you which one has it:

  • createProjectClient — the project key, server-side. What an operator configures: providers, models, limits, the capability ceiling, skills, MCP servers, webhooks, retrieval, the system prompt.
  • createClient — an end-user token, usable in a browser. What a user does: chat, their documents, memory, tools, sandboxes, voice — and audit, which needs a token minted with roles: ["admin"].

Two real confusions, in opposite directions, from people who knew the product well:

Reached forActually onWhy it is not obvious
project.audit.list(…)the end-user client, admin rolereading a trail feels like an operator's job
ai.retrieval.set(…)the project clientchoosing the embedding model feels like a user setting because retrieval is what a user experiences

And three names exist on both, meaning different things — the case where guessing wrong still compiles:

  • documents — the project client sees every document in the project; the end-user client sees what that user's token may see.
  • capabilities — the project client sets the project's ceiling; the end-user client reads what this token actually carries.
  • tasks — the project client manages the project's schedules; the end-user client manages that user's own.

createProjectClient also takes projectId and projectKey, not an apiKey.

The SDK has a server-side client for all of it:

npm install @oberik/sdk
import { createProjectClient } from "@oberik/sdk";

const oberik = createProjectClient({
projectId: process.env.OBERIK_PROJECT_ID!,
projectKey: process.env.OBERIK_PROJECT_KEY!,
});

await oberik.readiness(); // can this project answer yet?
await oberik.tokens.mint({ subject: "acme:ana", capabilities: ["chat"] });
await oberik.capabilities.set({ allowTodo: true, allowComputer: true });
await oberik.documents.upload(file, { tags: ["policies"] });
await oberik.systemPrompt.set("You are Acme's support agent.");
await oberik.origins.set(["https://app.acme.com"]);
await oberik.webhookTools.create({ name: "cancel_order", description: "…", url: "…" });
await oberik.keys.create("ci", { scope: "admin" });
await oberik.usage.observability();

It is a different client from the one your app uses, deliberately. That one takes an end-user token and belongs wherever your app runs; this one holds the project key and belongs only on your server — so it throws if it is ever constructed in a browser, rather than shipping and leaking the key to every visitor.

The curl equivalents are below, since everything here is one HTTP call. Each carries the same header:

X-API-Key: pk_…
Everything on this page needs an admin key

A project key carries a scope, and a new key defaults to mint — enough to mint end-user tokens and read the capability ceiling, and nothing else. That is what a backend usually wants, and it is what you should be holding unless a script genuinely administers the project.

The calls on this page are the rest of the Project API, so they need an admin key: it can change the capability ceiling, upload and delete documents, publish skills, create more keys, and delete the project. Treat one exactly like a root credential — server-side only, in a secret store, one per environment, revoked in the dashboard the moment it might have leaked. A mint key used here is refused with a 403 that names the scope it has.

Already-minted end-user tokens are unaffected by a revocation and expire on their own schedule.

Read the whole project

curl -s "$CP/api/projects/$OBERIK_PROJECT_ID" -H "X-API-Key: $OBERIK_PROJECT_KEY"

Returns the project, its capabilities ceiling, default model and context settings — the same object the dashboard renders. GET …/capabilities returns just the ceiling, with every flag present (an untouched one comes back false, not missing), plus supportedModalities: what the project's registered models can actually read and produce.

In the SDK, oberik.project.get() and oberik.project.set({ … }) cover this and the top-level fields (name, description, doneWebhookUrl), and oberik.project.citations() sets the marker style.

It also carries health and healthDetail — the same badge the dashboard renders on the project card, so you can show your own operators what Oberik shows you rather than reconstructing it:

const p = await oberik.project.get();
// p.health "ok" | "degraded" | "down"
// p.healthDetail "Every request fails until this is done — your own provider key, and the
// models it may use. POST /api/projects/:id/providers · dashboard: LLM &
// limits → Add provider"

healthDetail is a written sentence that leads with what still works and then names what does not; it is not reconstructable from the booleans. Both fields come from readiness below — one function computes them, so no surface can report a different badge than another.

Anything without a method: oberik.raw()

The project client wraps the routes people use most, and this page describes more than that. For any route with no method of its own, raw is the escape hatch — same key, same base URL, same error handling, so you never have to hand-roll a control-plane fetch:

await oberik.raw("GET", "/sandbox-host"); // what this deployment's sandboxes provide

The example here used to be raw("PUT", "/subagents", …), and then oberik.subagents.set() existed; the same has since happened to /computer. When a route grows a method, raw keeps working — it just stops being the shortest way.

Paths are relative to /api/projects/:projectId.

Set the capability ceiling

The ceiling is the maximum any token minted for this project may hold. Minting intersects with it, so a token can always be narrower and can never be wider.

curl -sX PATCH "$CP/api/projects/$OBERIK_PROJECT_ID" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"capabilities":{"allowTodo":true,"allowComputer":true,"allowApprovals":true}}'

Only the flags you send change; the rest are preserved, so you never have to read the ceiling back and resend it whole.

FieldTypeGrants the capability
allowChatbooleanchat
allowDocumentsbooleandocuments:read (+documents:write unless read-only)
documentsMode"read-write" | "read"read-only stops documents:write ever being minted
allowMemorybooleanmemory
sharedWikibooleanone wiki for the project instead of one per end-user
allowWebSearchbooleanweb_search
allowBrowserbooleanbrowser
allowBrowserHandoffbooleanbrowser_handoff
allowTodobooleantodo
allowComputerbooleancomputer
allowSubagentsbooleansubagents
allowSchedulingbooleantasks:read, tasks:write
allowTriggersbooleantriggers
allowAskUserbooleanask_user
allowApprovalsbooleanapprovals
allowUiToolsbooleanui_tools
allowFollowupsbooleanfollowups
allowRecapbooleanrecap
allowSteerbooleansteer
allowSystemPromptbooleansystem_prompt
allowWebhookToolsbooleanwebhook_tools
allowMcpbooleanmcp:manage
allowPluginUploadsbooleanplugins:write
allowActionSpacebooleanaction_space
inputModalitiesstring[]input:image … — bounded by what your models can read
outputModalitiesstring[]output:image … — file is always grantable
sessionVisibility"private" | "self"who may read a session — see below
defaultRoles / defaultGroupsstring[]used when a mint does not name its own. An explicit roles / groups replaces these — including [], which mints with none. admin, owner and service bypass per-user isolation, so do not put your own application's role names here
allowedToolsstring[] | nulla project-wide tool allow-list

The two modality lists take members of a closed set — image, audio, video, file — and anything else is refused, naming the set, the way sessionVisibility and documentsMode are. ["image"] used to be indistinguishable from ["banana"]: both were filtered out and both answered 200, so outputModalities: ["image"] could quietly leave a project able to produce nothing at all.

A real modality that no registered model supports is a different thing and still dropped on save rather than granted — otherwise every token would carry a promise the agent then refuses — but the response says so in warnings, naming the modality and what would make it stick. Read the response back either way: what it returns is what was stored.

private vs self

They are not synonyms, and the difference is about your staff rather than your end-users. Both keep one end-user out of another's conversations.

who can read a conversation
private (default)its owner, a tenant admin, and any token whose scope covers the owner — the last one is what makes a support view or a team-lead view possible
selfthe exact owner, and nobody else. An admin token is refused, and so is a scoped one

Pick self when even your own staff must not read the conversation — a therapy intake, an HR channel, a whistleblowing form. Everything else wants private, because with self a support engineer cannot see the thread a customer is complaining about, and no role or scope can be granted to change that.

A value outside the two is refused (sessionVisibility 'tenant' is not one of: private, self. Nothing was changed.) — deliberately not coerced to the safe default, because a caller who typed "tenant" meant something, and a 200 that quietly stored private would be the same defect one field over.

An empty string is refused too, and that is the case worth knowing, because nobody types it: it is what an untouched <select>, a cleared input, or --sessionVisibility "$UNSET_VAR" in a deploy script produces. It used to be stored — so a field deciding who may read your customers' conversations could be set to "" by a shell variable nobody had exported. To clear a setting back to its default, omit it or send null; "" is not a second spelling for that. (An empty query value is still absence — ?status_filter= means "no filter", as it does everywhere.)

A setting whose empty or null state removes a restriction costs an explicit confirm: true. That is the rule, and it exists because those writes are the ones with no symptom: clearing a restriction breaks nothing, raises no error, and leaves every existing caller working exactly as before — so it is found later, by somebody reading an invoice or an access log. The refusal counts what would go and says what it would mean:

origins.set([]) -> 400 this clears all 1 allowed origin(s), which lets ANY
origin call the API with this project's tokens.
limits.set({maxBudget: null, …}) -> 400 this removes every usage limit on the project,
including its spend cap…
capabilities.set({allowedTools: null}) -> 400 …every token this project has ever issued may
reach every tool its capabilities allow.
capabilities.set({maxToolIterations: null}) -> 400 …there is no platform bound behind it.

A widening you ask for by name is not guarded — allowComputer: true says what it does, and confirming it would be nagging. The guard is for the ones expressed as absence, where the call looks like tidying up a list.

Destructive deletes ask too, and the route is what asks

The rule above is about writes that remove a restriction. The mirror of it is deletes that remove a thing, and those carry ?confirm=true on the URL:

DELETE /providers/{credId} -> 400 Remove a provider credential and its model
deployments — this cannot be undone: the
credential cannot be read back — only a hint is
ever shown — and the model deployment(s)
registered from it go too. Nothing was changed.
Re-send with `?confirm=true` …
DELETE /providers/{credId}?confirm=true -> 204

Which deletes are gated is decided by what the caller can reconstruct, not by how irreversible they are. Removing a document, a wiki page, an organization, a project, an SSH key or a provider credential destroys the only copy of something; removing an MCP server, a webhook tool or a plugin destroys a URL and a name you still have. Revoking an API key is irreversible and deliberately not gated — revocation is the emergency path for a compromised key, and a prompt there is the wrong trade.

The guard lives on the route, so every client inherits it: --yes in the SSH gateway, the dashboard's confirmation modal and the SDK's { confirm: true } are three spellings of the same server-side rule. A client can decline to confirm; none of them can skip being asked.

await project.providers.delete(credId, { confirm: true });
await project.documents.delete(documentId, { confirm: true });
await project.wiki.delete(itemId, { confirm: true });
[] and null are not two spellings of the same thing

On allowedTools they are opposites, and GET /tools is where you see it:

allowedToolstools a token can reach
["read_file", "write_file"]those two
[]none at all — a list that permits nothing
nullevery one the capabilities allow — no list

Both cost a confirm, because both change what every token the project has ever issued may do, and each refusal says which of the two you are about to get. Note that origins is the other way round — there [] removes the restriction and lets any origin call. That is why each setting's refusal describes its own outcome rather than the gesture: "empty clears it" is not a rule that holds across the surface.

Over SSH, --allowed-tools "" sends the empty list, not the removal. Removing the restriction is allowedTools: null through the API or the SDK.

confirm is a top-level field on these calls, beside capabilities rather than inside it. The SDK takes it as a second argument on capabilities.set, origins.set, systemPrompt.set and defaultModel.set:

await project.capabilities.set({ allowedTools: null }, { confirm: true });

limits.set is the exception and takes it inside the object — limits.set({ maxBudget: null, budgetDuration: null, confirm: true }) — because that call is already a bag of optional fields rather than one value plus options. Over SSH every one of them is --confirm.

Numbers are refused the same way. A value outside a field's declared range is a 400 naming the bounds — execTimeoutS '0' must be at least 1. Nothing was changed. To remove the limit instead, send null. — rather than a 200 that stores something else. It used to be the latter, and the four fields involved did four different things: clamped at both ends, clamped at one, turned into null, or stored verbatim, all with warnings: null.

Two consequences worth knowing:

  • null (or a blank) is how you remove a limit, not 0. On execTimeoutS, maxSessions and subagents.maxConcurrent, null means "no project limit, use the deployment's" — so 0 used to be accepted and stored as the loosest setting, which is the opposite of what anyone typing it meant.
  • capabilities.maxToolIterations is the exception, because its own documentation has always said so: 0 there means "no project limit" and reads back as null.

A key that is not in this table is rejected with a 400 naming it, rather than merged and ignored. That matters most for fields that look like they belong here and don't:

FieldTypeWhere it actually goes
doneWebhookUrlstringtop level, beside name — not inside capabilities
# right
curl -sX PATCH "$CP/api/projects/$ID" -H "X-API-Key: $KEY" \
-d '{"doneWebhookUrl":"https://example.com/hooks/oberik"}'

doneWebhookUrl is not a capability: an end-user token cannot ask for less of it, so it is a property of the project's behaviour rather than a permission a token carries. Read it back from GET /api/projects/:id, which returns it alongside the other project fields; GET /api/projects/:id/capabilities deliberately does not, because it answers "what may a token do".

Curate the corpus

Documents uploaded with the project key belong to the project rather than to any one end-user, which is the shape most integrations want: you curate, your users ask.

curl -sX POST "$CP/api/projects/$OBERIK_PROJECT_ID/documents" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" \
-F file=@handbook.pdf -F tags=policies

curl -s "$CP/api/projects/$OBERIK_PROJECT_ID/documents" -H "X-API-Key: $OBERIK_PROJECT_KEY"
curl -sX DELETE "$CP/api/projects/$OBERIK_PROJECT_ID/documents/$DOC_ID" -H "X-API-Key: $OBERIK_PROJECT_KEY"

The listing is a page{ items, has_more, next_offset } — with the same limit (default 100, max 500), offset, tag and status_filter the data plane takes, and with_total for the census:

const page = await oberik.documents.list({ with_total: true });
page.total; // how many documents this project holds
page.items; // the first 100, newest first

with_total costs a second query, so it is for a summary rather than for paging — "is there another page" is has_more, and that is free.

Pair this with documentsMode: "read" and your end-users can search the corpus and never add to it. The full document surface — visibility, ACLs, chunk inspection, re-ingestion — is on the data plane and covered in Documents.

The system prompt

curl -sX PUT "$CP/api/projects/$OBERIK_PROJECT_ID/system-prompt" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"systemPrompt":"You are Acme’s support agent. Never speculate about pricing."}'

Goes into every request for the project, first, marked authoritative. A caller's own system_prompt is appended after it and told the project's wins — see System prompts. Send "" to clear.

Skills, webhook tools and MCP servers

# Publish a skill to everyone using this project: a packaged plugin, a zipped folder
# of skills, or a single SKILL.md — a manifest is written when there isn't one.
curl -sX POST "$CP/api/projects/$OBERIK_PROJECT_ID/skills" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -F file=@refunds.zip

# Publish a tool the agent calls by URL. The signing secret comes back once.
curl -sX POST "$CP/api/projects/$OBERIK_PROJECT_ID/webhook-tools" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"name":"cancel_order","description":"Cancel an order belonging to the signed-in customer.",
"url":"https://acme.com/agent/orders",
"parameters":{"type":"object","properties":{"order_id":{"type":"string"}},"required":["order_id"]}}'

# Attach an MCP server to the project's agent.
curl -sX POST "$CP/api/projects/$OBERIK_PROJECT_ID/mcp" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"name":"orders","url":"https://mcp.acme.com/sse","transport":"sse","headers":{"authorization":"Bearer …"}}'

All three are the project's own and apply to every end-user — though a webhook tool is only bound to turns whose token carries webhook_tools, so publishing one and granting it are separate decisions. See Tools you host. An end-user uploading their own skill is a different thing, needs plugins:write, and goes straight to the data plane with their token — Skills.

Browser origins

Required before any browser calls the API with your tokens.

curl -sX PUT "$CP/api/projects/$OBERIK_PROJECT_ID/origins" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"origins":["https://app.acme.com","http://localhost:3000"]}'

Exact origins and single-level wildcards — Calling from the browser. This REPLACES the list; clearing it removes the restriction entirely and takes "confirm": true (origins.set([], { confirm: true }) in the SDK).

Models, retrieval and context

MethodPathSets
GET/api/providers/catalogwhich providers exist and what credentials each needs
GET POST DELETE/api/projects/:id/providers[/:credId]your own LLM keys and the models they register
POST/api/projects/:id/providers/:credId/refreshre-read a provider's catalog and prices
GET POST/api/projects/:id/default-modelwhich model is used when a request names none
GET POST/api/projects/:id/model-tiersroute a request to one of three models by how much work it looks like
PUT/api/projects/:id/retrievalembedding and rerank model overrides
PUT/api/projects/:id/document-processorwho does OCR: auto, a specific model, or local
GET/api/projects/:id/contexthow long conversations are kept in the window
GET/api/projects/:id/limitsusage caps on the project's LLM key

A model registered without a known price logs spend as zero, which means a usage cap cannot trip on it — refresh is what fixes that.

providers catalog says which credential fields each provider requires, and registering one without them is refused. Everything the catalog lists for a provider is required unless it is marked optional — a self-hosted vLLM needs api_base and may have no key at all, a hosted API is the other way round. The refusal names the field, its label and the JSON shape that supplies it, and nothing is registered.

A credential stored before that check existed is a different thing and is not refusable after the fact, so readiness raises it instead: its provider step reports the missing field and the call that fixes it.

readiness also carries health and healthDetail — it is the route that computes their inputs, so it is the route that answers the question directly.

A base URL that cannot be reached is caught when you register it. For a provider whose endpoint you supply — a self-hosted vLLM, anything custom — the URL is asked for /models once, at registration, and the answer is kept. A host that does not resolve makes provider the blocking step, so canAnswer is false and next names the URL and the call that corrects it, instead of sending you off to configure retrieval. Registration still succeeds: an endpoint can be down now and up in an hour, and refusing would turn a temporary outage into a permanent inability to configure. Correcting the URL asks again.

Only a transport failure counts — DNS, connect, TLS, a timeout. Any HTTP response at all, including a 404 from a server with no /models and a 401 wanting a different key, proves the host is there, which is the only question being asked.

What readiness does and does not ask

Every step except rerank-model and provider answers "is this configured", not "can it be reached", and those two ask at different moments. rerank-model is measured by reranking with it, and re-measured whenever the credential behind it changes. provider reads the verdict recorded when the credential was written — so an endpoint that worked at registration and stopped afterwards is not noticed here; the first thing that fails is a turn or an upload.

chat-model and default-model never ask. A model name that resolves to nothing passes both, and canAnswer stays true.

One model per request, instead of one per project

A project registers several models and one of them is the default, so every request that names no model gets the same one whatever it asks for: "what time do you close?" answered by the model you bought for the hard questions. Pointing the default at something cheaper fixes the bill and quietly degrades the work.

model-tiers lets the request pick:

await project.modelTiers.set({
mode: "auto",
simple: "gpt-5-mini", // a short question with nothing asked for
normal: "hukuk-db", // everything else
complex: "claude-opus-5", // a file, the sandbox, delegates, several steps, a long brief
});
A request that names a modelis never re-routed. The tiers answer "and if they didn't say?"
A tier left emptyfalls back to the project's default model, so a half-configured project behaves exactly as before
mode: "off" (the default)one default model, as it has always been
A resumed turnstays on the model it started on. A tool result is a short message with nothing asked for, so re-sorting it would finish a hard turn on the cheap model
The token's own limitsapply unchanged: a routed model is checked against allowed_models and against what the project registered, exactly as a named one is

The sorting reads the request — it does not ask a model. A classifier call in front of every turn would cost latency on the fast path this exists to make fast, and spend money to decide how much money to spend. So it reads what came with the request (a file, named documents), what you switched on on that request (the sandbox, delegates, generated media) and the shape of the message (its length, a numbered list, "then … then"). enable_computer and enable_subagents default to true — that is a permission, not a request, and a turn that simply may use the sandbox is not sorted complex for it. It is wrong sometimes, and being wrong means a neighbouring model answers rather than the turn failing. It errs upward on purpose: a hard question on the cheap model is a bad answer somebody has to notice, and an easy one on the strong model costs a fraction of a cent.

Every answer carries model and model_tier, which is how you tune it — without them a routing mistake and a model being bad at something are indistinguishable, and there is no way to notice that everything is being sorted complex.

Reading a setting and replacing one are different requests

POST …/default-model and PUT …/limits replace what is there. Sending a body with no fields is refused rather than read as "clear it", and clearing something that is set takes "confirm": true — because from a shell the natural way to look at a setting is to type its command with no arguments, and for a while that deleted the thing you were looking at. GET …/default-model and GET …/limits are the reads.

That is not hypothetical: a bug filed as "adding a provider does not store the default model" turned out to be the reporter's own next command — a bare default-model, run to check — wiping it and answering {"defaultModel": null}, which reads exactly like a getter confirming the problem.

Four settings are guarded this way, and every one of them can be cleared from the SDK:

await project.origins.set([], { confirm: true }); // removes the origin restriction
await project.systemPrompt.set("", { confirm: true }); // deletes the project prompt
await project.defaultModel.set("", { confirm: true }); // every unnamed-model request then fails
await project.limits.set({ maxBudget: null, budgetDuration: null, confirm: true }); // uncaps spend
await project.capabilities.set({ allowedTools: null }, { confirm: true }); // every token reaches every tool

The API says what each would remove before it refuses, so a script that resets a project to a known state reads the refusal rather than guessing.

Telling Oberik what a model can do

Most models are described by their provider's own catalog, or by the published model index. A self-hosted one is described by nothing: vllm serve --served-model-name hukuk-db reports an id no index carries, over a /v1/models that publishes no capabilities. So it is recorded conservatively — text in, text out — and the project's input:image ceiling follows from that. GET /providers marks such a model "derivedFrom": "name", which is the word for "nobody knew".

modelFacts is how you correct it, keyed by model name, and it can be sent on its own:

curl -sX PATCH "$CP/api/projects/$OBERIK_PROJECT_ID/providers/$CRED_ID" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"modelFacts":{"hukuk-db":{"input":["text","image"],"output":["text"],"mode":"chat"}}}'
await oberik.providers.update(credId, {
modelFacts: { "hukuk-db": { input: ["text", "image"], output: ["text"], mode: "chat" } },
});

Nothing is re-registered — a deployment is created from a name and a price, and what a model can read is metadata. input decides whether the agent may put a picture or a PDF in front of it; output and mode decide which pickers offer it and which modalities the project may grant. The dashboard asks the same question under LLM & limits → the model's row.

Rotating a key

The same call replaces a credential, and it takes effect on the next turn:

await oberik.providers.update(credId, { values: { api_key: NEW_KEY } });

Every model on that credential is re-registered against the new value, because a model deployment is built from the key and keeps its own copy of it — storing a new one without rebuilding them would leave the platform using the key you just revoked. Your declared modelFacts are carried across untouched: rotating a key is not a catalog re-read.

Re-reading the catalog is providers.refresh(credId), and it is a different job — it asks the provider what its models can do and what they now cost, which is worth doing when a provider adds a modality or publishes a price. A model registered before its price was published logs a spend of zero, so a refresh is also how a usage cap starts working.

Name a model exactly as GET /providers lists it. That is the form /chat accepts and the form default-model stores, and for most providers it is what you would guess: openai/gpt-4o, anthropic/claude-sonnet-4.6. For a self-hosted provider (vLLM, LM Studio, an OpenAI-compatible gateway) it is the served model name on its own — the id its /v1/models returns, e.g. hukuk-db — with no provider prefix. vllm/hukuk-db looks right and is not a model: default-model refuses it with the list of what is registered, and /chat refuses it the same way.

Watch what it costs

curl -s "$CP/api/projects/$OBERIK_PROJECT_ID/usage" -H "X-API-Key: $OBERIK_PROJECT_KEY"
curl -s "$CP/api/projects/$OBERIK_PROJECT_ID/observability?window=86400" -H "X-API-Key: $OBERIK_PROJECT_KEY"

observability returns spend, requests, tokens and p95 latency as time series, plus per-model and per-user breakdowns — spend is attributed to the token's subject, so this is where "which customer is this costing us" is answered.

Every billable call names an actor, including the ones you never asked for directly: the retrieval embedding and the rerank behind an answer, the guardrail check, the history summarisation, and the OCR and embeddings of a document are all billed to whoever caused them. A call your backend makes with a project API key is attributed to the service principal rather than to an end-user, because that is who made it. A byUser row with unattributed: true is therefore a bug on our side — not a category of usage — and worth reporting.

There are two spend figures and they answer different questions:

FieldWhat it isUse it for
spendBilledcumulative charge from the LLM gateway's own billing recordinvoicing, "what has this project cost"
totals.spendmetered spend over the requested windowtrends, per-model and per-user attribution

spendBilled is the figure spend caps are enforced against, and it is null — never 0 — when the billing record could not be read. spendBilledPeriod says what it covers: all time, or per 30d when the project has a reset window, because the gateway zeroes the figure at each rollover.

Everything windowed reconciles: totals is the sum of its own series and the sum of either breakdown, because all of them are derived from the same readings. The last point of every series is the bucket still fillingpartialFrom is its timestamp, and stepSeconds is how wide a bucket is. It covers a period that has not finished, so it is lower than the ones before it; draw it as in-progress rather than as a drop in usage.

Both endpoints carry metricsAvailable. When it is false the numbers are unknown, not zero — the metrics backend could not be read — and metricsError says why. Two more fields say when a window is incomplete rather than quiet: metricsRestarts counts metrics-collector restarts (a deploy resets its in-process counters, losing up to one bucket around each), and metricsGaps counts buckets where nothing could be read at all. spendBilled and spend caps are unaffected by any of these, so a project can be over its cap while the metered view reports nothing.

GET /api/orgs/:orgId/costs does the same across every project in an organization, but it needs a signed-in session rather than a project key: a project key belongs to one project, and an org-wide breakdown is by definition everyone else's numbers too.

Sessions, tasks and sandboxes

MethodPathReturns
GET/api/projects/:id/sessionsevery chat session in the project
GET/api/projects/:id/sessions/:sessionId/messagesone session's messages and tool calls
GET/api/projects/:id/tasksscheduled tasks and reminders
GET/api/projects/:id/wikipages the agent has written
DELETE/api/projects/:id/wiki/:itemIdremove a page or a memory
GET/api/projects/:id/sandboxeslive sandboxes
POST/api/projects/:id/sandboxes/:sessionId/:actionpause, resume or delete one

These read across the whole project, unlike the data-plane equivalents, which are bounded by the calling token's scope. That is the difference between an operator view and an end-user's.

Keys

curl -sX POST "$CP/api/projects/$OBERIK_PROJECT_ID/keys" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \
-d '{"name":"ci","scope":"mint"}'
# -> { "id": "…", "name": "ci", "scope": "mint", "prefix": "pk_ab12", "key": "pk_…" }
# `key` is shown once and never again — only its hash is stored.
# scope: "mint" (default) mints end-user tokens and reads the ceiling; "admin" is
# everything on this page, including issuing keys and deleting the project.

curl -s "$CP/api/projects/$OBERIK_PROJECT_ID/keys" -H "X-API-Key: $OBERIK_PROJECT_KEY"
curl -sX DELETE "$CP/api/projects/$OBERIK_PROJECT_ID/keys/$KEY_ID" -H "X-API-Key: $OBERIK_PROJECT_KEY"

In the SDK those three are keys.create(name, { scope }), keys.list() and keys.revoke(keyId) — the delete is named revoke rather than delete, so searching the client for the endpoint's own word finds nothing.

Creating the project itself

The routes above all authenticate with a project key, which leaves one question: where the project and its first key come from. Both need a signed-in account rather than a key — but that does not mean a browser. The SSH interface is the same control plane as text:

ssh ssh.oberik.com 'login link' # prints a URL; a person approves it once
ssh ssh.oberik.com 'login wait' # blocks until they do
ssh ssh.oberik.com 'org new "Acme"'
ssh ssh.oberik.com 'project new "Support Bot"'
ssh ssh.oberik.com 'project use "Support Bot"; key new ci' # pk_… shown once

One human approval, once — which is the right amount of consent for handing something an account — and after that the SSH key is registered, so the same commands run unattended from CI.

Deleting a project

A project is an isolated tenant, so deleting one is a data-deletion: its documents, agent knowledge, conversations, uploaded files, sandboxes and scheduled tasks are erased on the data plane, and its API keys, provider credentials and LLM access go with it. Every token minted for it stops working. There is no undo and no export.

The project's name is the confirmation, and it is required. The risk here is deleting the wrong project, and confirm=true cannot tell you which one you are on — a flag is something a client sends by default, while a name is something it has to have been told. So the name answers which, and it is the part that makes this safe; confirm=true answers whether, and it is there because every gated delete takes it and an exception is how a rule stops holding.

curl -sX DELETE "$CP/api/projects/$ID?name=Support%20Bot&confirm=true" -H "X-API-Key: $KEY"
# -> { "id":"…", "name":"Support Bot",
# "rowsDeleted": { "project_keys":2, "provider_credentials":1, "projects":1 },
# "dataPlane": { "rows_deleted": { "documents":41, "chat_sessions":12 },
# "sandboxes_destroyed":1, "schedules_cancelled":2,
# "vectors_purged":1204, "objects_deleted":57 } }

A name that does not match answers 400 and deletes nothing. The response says what the erasure actually reached — a null under dataPlane means that store could not be reached, and if the tenant erase fails the request fails with 502 and the project is left intact, because a project row deleted while its tenant survives would leave the customer's data unreachable forever.

Needs an admin key (a mint key is refused), or a signed-in owner/admin of the organization. From the dashboard it is Overview → Danger zone; over SSH it is project rm "<name>".

A project as code

Applying a known configuration on deploy, rather than remembering which toggles were set:

scripts/configure-oberik.ts
import { readFile } from "node:fs/promises";

const CP = "https://oberik.com";
const id = process.env.OBERIK_PROJECT_ID!;
const headers = {
"X-API-Key": process.env.OBERIK_PROJECT_KEY!,
"content-type": "application/json",
};

const put = (path: string, body: unknown, method = "PUT") =>
fetch(`${CP}/api/projects/${id}${path}`, { method, headers, body: JSON.stringify(body) })
.then(async (r) => (r.ok ? r.json() : Promise.reject(new Error(`${path}: ${r.status} ${await r.text()}`))));

await put("", {
capabilities: {
allowChat: true,
allowDocuments: true,
documentsMode: "read", // we curate the corpus; users only read it
allowTodo: true,
allowApprovals: true, // stop before anything irreversible
allowAskUser: false, // no human on the other end of our batch jobs
outputModalities: ["file"], // it may hand back documents it writes
},
}, "PATCH");

await put("/system-prompt", { systemPrompt: await readFile("prompts/support.md", "utf8") });
await put("/origins", { origins: ["https://app.acme.com"] });

console.log("configured");

Re-running this is safe: the capability patch merges, and the other two set a value rather than appending to one.