Skip to content

API Keys and Connecting Clients

Nothing reaches your tenant without an API key. Keys are issued on API Keys (/app/api-keys) and used by MCP clients, your own applications, and the ingestion API.

Create a key

Click Create key, give it a display name you will recognise in six months, and select its scopes. The key is shown once, at creation. It cannot be retrieved afterwards — copy it then, or create another one.

A key can only delegate scopes it already holds. A key you create can be narrower than yours but never wider — the console disables the scopes you cannot confer, and the engine refuses them regardless. That is what makes it safe to hand a subordinate key to an application.

The owner key

One key per tenant can carry the owner role. It acts with every scope this engine defines — including scopes added in later releases, and database sources created after the key was issued — without any of them being written into its scope list. In the table it shows an owner badge and its scopes column reads every scope, from the owner role.

That last part is the point. A key holding a fixed list of scopes goes stale: add a database source and the key cannot query it until someone grants dbsource:<name>:read, which only an operator can do. An owner key can delegate that scope to a subordinate the moment the source exists.

Two limits worth knowing:

  • Only your operator can assign it. There is no control for it in the tenant console, and POST /v1/tenant/keys refuses a role in the body rather than quietly issuing an ordinary key. Ask your operator if you need one.
  • It never confers dbsource:<name>:unfiltered. Per-actor row filtering is not something the role can switch off.

Promoting a key does not rewrite its stored scopes, so demoting returns it to exactly what it held before, on its next request.

Scopes

Give each key the least it needs. A search client has no reason to hold config:write.

ScopeAllows
codebase:readSearch, graph tools, workspace overview.
codebase:writeIngest and delete content.
memory:readRecall memories and read memory status.
memory:writeStore, forget, and ingest memories.
config:read / config:writeRead or change your tenant config.
generation:reloadApply a reload.
api_keys:manageCreate and revoke keys.
directory:manageManage actors and groups.
connections:manageAdd and authorize connections.
dbsource:<name>:readQuery one database source.
dbsource:<name>:syncSync one database source, and edit its verified queries.
connection:<name>:read / :syncRead or sync one external connection.
actor:assertAct on behalf of any actor via a header.
actor:bind:<actor_id>Permanently act as one actor.

* in place of a name covers every resource in that family: dbsource:*:read reaches every database source in your tenant — the ones you have now and the ones you add later — and never another tenant's. The same holds for connection:*. The implication runs one way only: holding dbsource:crm:read does not let you grant dbsource:*:read, and a read never implies a sync.

There is also dbsource:<name>:unfiltered, which queries a source without per-actor row filtering. It exists for an operator's smoke test, it cannot be delegated to a subordinate key, and the owner role does not confer it.

Identifying the end user

Two ways, and the right choice depends on who holds the key.

  • Bound key — for end users. Create the key with actor:bind:<actor_id>. It always acts as that actor, needs no extra header, and a request asserting a different actor is rejected — users cannot impersonate one another. Keys delegated from it inherit the binding. No actor:assert grant is needed.
  • Per-request header — for trusted daemons. A key holding actor:assert may send x-ugent-actor: <actor_id> per request, so one service can serve many users. Only give this to software you control.

Either way the actor must exist in your Directory first, or the request is denied.

Connect an MCP client

Connect MCP (/app/connect-mcp) generates the key and writes the finished command in one step. Choose who the key acts as, choose its scopes, and the page returns the command with the key already in it — there is no placeholder to edit and no second page to visit. Tabs for Claude Code, Codex, ugent and generic mcp.json reformat the same key, so one key serves every client.

The token is shown once. It exists only in your browser, is never part of the page's server-rendered source, and closing the panel clears it; recovery means revoking the key and generating another.

The transport is JSON-RPC over HTTP:

POST https://<your-data-plane>/rpc
Authorization: Bearer ugctx_...

POST /mcp is served as an alias and behaves identically. /rpc is canonical; use whichever your client's documentation assumes.

What the page emits, per client:

sh
# Claude Code
claude mcp add --transport http ugent-context https://<your-data-plane>/rpc \
  --header "Authorization: Bearer ugctx_..."
sh
# Codex — the key goes in the environment, not in config.toml
export UGENT_CONTEXT_KEY='ugctx_...'
codex mcp add ugent-context --url https://<your-data-plane>/rpc \
  --bearer-token-env-var UGENT_CONTEXT_KEY

Codex reads that variable when it runs, so put the export in a shell profile or the next terminal has no credential.

sh
# ugent — writes the ugent.toml block for you
ugent mcp add ugent-context --url https://<your-data-plane>/rpc \
  --header "Authorization: Bearer ugctx_..."

Better still on ugent: put the key in the vault and swap --header for --token-ref @your_ref, and it never reaches the file at all — which is more than the other clients here can do. Check it with ugent mcp test ugent-context, which connects and lists the tools.

json
{
  "mcpServers": {
    "ugent-context": {
      "url": "https://<your-data-plane>/rpc",
      "headers": { "Authorization": "Bearer ugctx_..." }
    }
  }
}

None of these sends x-ugent-actor, because the generated key is bound with actor:bind:<actor_id> and the binding already carries the identity — a header would be a second opinion for the engine to disagree with. Add the header only for a key holding actor:assert, which is for a daemon acting on behalf of many people.

Generating an unbound key is still possible, and on a deployment that requires an actor the page says plainly what will happen: it will connect, list tools, and then fail every call.

Verify with a real call, not tools/list

bash
curl -s https://<your-data-plane>/rpc \
  -H "Authorization: Bearer ugctx_your_key_here" \
  -H "x-ugent-actor: your-actor-id" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"workspace_overview","arguments":{}}}'

tools/list is a poor smoke test and it is worth knowing why. It does need a credential — an unauthenticated list is refused with a 401 — but that is all it checks. Listing is not gated on an actor or on owning a workspace, so on a deployment that requires an actor it answers with the full catalogue for a key that cannot call a single tool. You get a green light, wire up the client, and every real query fails. workspace_overview takes no arguments and goes through the same checks a tool call does, so it proves the thing you care about.

The tools

ToolScopeDoes
workspace_overviewcodebase:readLists the workspaces you can reach, with graph statistics. Call this first to discover workspace ids. Pass codebase_id plus aspects for an architecture report of one workspace.
workspace_searchcodebase:readHybrid search with snippet, structure, and synthesis modes.
graph_searchcodebase:readFind code entities by name; returns node ids.
graph_neighborscodebase:readCallers, callees, imports, and references around a node.
database_querydbsource:<name>:readRead-only SQL, or a plain-English question, against a database source.
memory_recallmemory:readRecall stored memories by query or filter.
memory_storememory:writeCreate or update one memory.
memory_forgetmemory:writeDelete memories by id or filter.
memory_ingestmemory:writeQueue a transcript for async memory creation.
memory_ingest_statusmemory:readPoll an ingest job.

The memory tools appear in tools/list only when your operator has enabled them for MCP; a key with memory:* scopes can still call them directly.

When it does not work

Read the HTTP status first

The JSON-RPC error code tells you what went wrong; the HTTP status tells you who has to fix it, and it is the part a proxy or a client library acts on without reading the body.

StatusMeansWhat helps
401The engine does not know who you are — no Authorization header, or a key it does not accept. The response carries a WWW-Authenticate: Bearer challenge, and adds error="invalid_token" when a key was sent and rejected.Fix the key. Sending the same one again cannot help.
403You authenticated and the answer is still no. error.data.code says which: missing_scope, codebase_not_registered, or actor_required.Widen the key's scopes, register the workspace, or send an actor.
503The engine could not reach a backend. Nothing to do with your key.Retry. Tell your operator if it persists.
200The request reached a tool. A tool that ran and failed reports it in the body, not the status.Read error or isError in the response.

A missing actor is a 403, not a 401. This matters if your client treats 401 as "the session expired" and re-authenticates or redirects to a login: the key is fine, and no amount of re-authenticating will fix it. The request needs an x-ugent-actor header.

  • Empty search results. Usually not an auth problem: check Workspaces actually has a workspace in it. Searching before the first ingest returns nothing, correctly.
  • A tool is missing from tools/list. The key lacks that tool's scope, or the subsystem is off on this deployment.
  • -32001 saying the deployment requires an actor (HTTP 403). No actor was asserted. Send x-ugent-actor with a key holding actor:assert, or use a key bound with actor:bind:<actor_id>. A key with only codebase:read authenticates and lists tools here, then fails every call — so check this before suspecting scopes.
  • -32001 naming an actor. That actor is not in your Directory, or the key is bound to a different one than it asserted, or it lacks actor:assert and may not assert one at all.
  • Results are missing for one user but not another. That is access control working. Use What would this actor see? to find out why.
  • -32004. A quota or record limit was hit, not a bug.

Revoking

Revoke a key from the API Keys table the moment it is no longer needed, or if it might have leaked. Revocation takes effect immediately; anything using that key starts failing authentication.

Keys delegated from it are not revoked with it. A delegated key is an independent credential — the engine records no parent, so there is nothing to cascade along. If a key leaked, revoke every key that was created from it, one by one. The API Keys table lists them all; if you cannot tell which came from where, revoke and re-issue the ones you cannot account for.

You cannot revoke or edit the key you are signed in with. Both are refused with a 403, and the row is marked this session. They are one-way: dropping api_keys:manage from your own key removes the authority to put it back, and revoking it takes effect on your next request with nothing left able to undo it. Use a second key holding api_keys:manage, or ask your operator.

Released under the Private Beta License.