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 — andaudit, which needs a token minted withroles: ["admin"].
Two real confusions, in opposite directions, from people who knew the product well:
| Reached for | Actually on | Why it is not obvious |
|---|---|---|
project.audit.list(…) | the end-user client, admin role | reading a trail feels like an operator's job |
ai.retrieval.set(…) | the project client | choosing 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_…
admin keyA 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.
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.
| Field | Type | Grants the capability |
|---|---|---|
allowChat | boolean | chat |
allowDocuments | boolean | documents:read (+documents:write unless read-only) |
documentsMode | "read-write" | "read" | read-only stops documents:write ever being minted |
allowMemory | boolean | memory |
sharedWiki | boolean | one wiki for the project instead of one per end-user |
allowWebSearch | boolean | web_search |
allowBrowser | boolean | browser |
allowBrowserHandoff | boolean | browser_handoff |
allowTodo | boolean | todo |
allowComputer | boolean | computer |
allowSubagents | boolean | subagents |
allowScheduling | boolean | tasks:read, tasks:write |
allowTriggers | boolean | triggers |
allowAskUser | boolean | ask_user |
allowApprovals | boolean | approvals |
allowUiTools | boolean | ui_tools |
allowFollowups | boolean | followups |
allowRecap | boolean | recap |
allowSteer | boolean | steer |
allowSystemPrompt | boolean | system_prompt |
allowWebhookTools | boolean | webhook_tools |
allowMcp | boolean | mcp:manage |
allowPluginUploads | boolean | plugins:write |
allowActionSpace | boolean | action_space |
inputModalities | string[] | input:image … — bounded by what your models can read |
outputModalities | string[] | output:image … — file is always grantable |
sessionVisibility | "private" | "self" | who may read a session — see below |
defaultRoles / defaultGroups | string[] | 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 |
allowedTools | string[] | null | a 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 |
self | the 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 thingOn allowedTools they are opposites, and GET /tools is where you see it:
allowedTools | tools a token can reach |
|---|---|
["read_file", "write_file"] | those two |
[] | none at all — a list that permits nothing |
null | every 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, not0. OnexecTimeoutS,maxSessionsandsubagents.maxConcurrent,nullmeans "no project limit, use the deployment's" — so0used to be accepted and stored as the loosest setting, which is the opposite of what anyone typing it meant.capabilities.maxToolIterationsis the exception, because its own documentation has always said so:0there means "no project limit" and reads back asnull.
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:
| Field | Type | Where it actually goes |
|---|---|---|
doneWebhookUrl | string | top 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
| Method | Path | Sets |
|---|---|---|
GET | /api/providers/catalog | which 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/refresh | re-read a provider's catalog and prices |
GET POST | /api/projects/:id/default-model | which model is used when a request names none |
GET POST | /api/projects/:id/model-tiers | route a request to one of three models by how much work it looks like |
PUT | /api/projects/:id/retrieval | embedding and rerank model overrides |
PUT | /api/projects/:id/document-processor | who does OCR: auto, a specific model, or local |
GET | /api/projects/:id/context | how long conversations are kept in the window |
GET | /api/projects/:id/limits | usage 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.
readiness does and does not askEvery 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 model | is never re-routed. The tiers answer "and if they didn't say?" |
| A tier left empty | falls 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 turn | stays 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 limits | apply 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.
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:
| Field | What it is | Use it for |
|---|---|---|
spendBilled | cumulative charge from the LLM gateway's own billing record | invoicing, "what has this project cost" |
totals.spend | metered spend over the requested window | trends, 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 filling — partialFrom 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
| Method | Path | Returns |
|---|---|---|
GET | /api/projects/:id/sessions | every chat session in the project |
GET | /api/projects/:id/sessions/:sessionId/messages | one session's messages and tool calls |
GET | /api/projects/:id/tasks | scheduled tasks and reminders |
GET | /api/projects/:id/wiki | pages the agent has written |
DELETE | /api/projects/:id/wiki/:itemId | remove a page or a memory |
GET | /api/projects/:id/sandboxes | live sandboxes |
POST | /api/projects/:id/sandboxes/:sessionId/:action | pause, 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:
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.