Agent exposure
By the end of this page you will have exposed an agent on an installed appliance and called it with an API key.
An appliance has no Studio, so exposure is set in one file on the install host: config/api-keys.yaml. The gateway re-reads it within about 5 seconds of a save, so exposing an agent, adding a key or revoking one never needs a restart.
Prerequisites
- An installed, running appliance. See Installing it.
- Shell access to the install host, in the archive folder.
- Model credentials in
.env. The appliance never carries qRaptor’s model credentials, so an AI agent or multi-agent system can’t answer until you add your own provider’s key. See Connecting models. - The roles your agents’ data needs. A key acts with the roles you give it, and only sees the tables those roles can read. See Give the key a role.
How it works
A call goes through only when both of these allow it:
| Controls | On a fresh install | |
|---|---|---|
| Exposure | Whether an agent or multi-agent system can be called with a key at all | Every agent and system is off |
| API key | Who is calling, what they may do (scopes), which roles it acts with, and optionally which agents they may reach | Two sample keys that don’t work until you replace their values |
Keys only reach agent, multi-agent system and conversation endpoints — never your app’s own /api routes.
Find your agents’ endpoints
Each export lists the endpoints of every agent and system it carries, with their IDs:
- Studio — the export’s API access tab on the project’s Appliance page.
- The archive — the API access section of
README.md.
| Agent type | Endpoint | Purpose | Scope |
|---|---|---|---|
| AI agent | POST /agents/{id}/run | Ask once, get a JSON answer | invoke |
POST /agents/{id}/stream | Same, streamed | invoke | |
POST /agents/{id}/conversation | Multi-turn chat | invoke | |
GET /agents/{id}/info | Agent details | info:read | |
GET /agents/{id}/runs | Past runs | runs:read | |
| Task Agent | POST /agents/{id}/run | Run with input variables | invoke |
GET /agents/{id}/runs | Past runs | runs:read | |
GET /agents/{id}/runs/{runId} | One run’s result | runs:read | |
GET /agents/{id}/runs/{runId}/stream | One run’s step-by-step trace | runs:read | |
| Multi-agent system | POST /mas/{id}/conversation/stream | Send a message, streamed | invoke |
GET /mas/{id}/agents/skills | Member agents and their skills | info:read |
Expose an agent
Generate a key
KEY="qr_$(openssl rand -hex 24)"
echo "$KEY"Keys must start with qr_. Save the value in your secrets manager.
Turn on exposure
In config/api-keys.yaml, set enabled: true on each agent or system you want callable:
exposed:
agents:
- id: 3f6c2a10-7d4e-4b8a-9c21-5e0f8a7b6d34
name: Order Details Agent
type: task_agent
enabled: trueKeep the IDs already in your file — they’re your export’s agents.
Add the key
Under keys, replace a sample’s key: value with your key, or add an entry:
keys:
- name: order-portal
key: qr_your_generated_key
scopes: [invoke, "runs:read", "info:read"]
roles: [admin]
restrict_to:
- 3f6c2a10-7d4e-4b8a-9c21-5e0f8a7b6d34
enabled: truename shows up in logs and run history. roles decides which tables the agent can read on this key’s behalf — see Give the key a role. restrict_to limits the key to the listed IDs; leave it out to allow every exposed agent.
Save and confirm
docker compose logs gateway --since 1m | grep appliance_apiLook for appliance_api_keys_loaded … keys=1 exposed_agents=1.
Call it
curl -sS -X POST "https://apps.example.com/agents/3f6c2a10-7d4e-4b8a-9c21-5e0f8a7b6d34/run" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "3f6c2a10-7d4e-4b8a-9c21-5e0f8a7b6d34",
"variables": { "order_id": "0b9e7d21-4c3a-4f5e-8a6b-7c1d2e3f4a5b" },
"execution_mode": "production"
}'Use your QR_PUBLIC_URL as the address.
Give the key a role
An agent reads your app’s data as the key that called it. The key’s roles decide which tables it can see, with the same data governance rules your app’s users get — the permissions you set in Studio under your app’s security travel with the export.
A key with no roles acts as guest. If guest can’t read a table, the agent can’t either: it reports the table doesn’t exist, finds no rows, or gives a vague answer — the call itself still returns 200.
To fix it, give the key a role that can read what the agent needs:
- name: order-portal
key: qr_your_generated_key
scopes: [invoke, "runs:read", "info:read"]
roles: [admin]Roles and their table permissions come from your project and are sealed in the export — you can’t widen them on the appliance. If no role can read a table an agent needs, grant it in Studio under your app’s security, then export again. Previewing an agent in Studio runs as you, the builder, and skips these permissions, so test with the role your integration will use.
Choose the narrowest role that works. A key with admin reads everything admin can — for a public-facing integration, prefer a role limited to the tables it needs.
Request examples
Every request sends Authorization: Bearer qr_…. Set BASE to your QR_PUBLIC_URL.
AI agent — ask once:
curl -sS -X POST "$BASE/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"message": "fetch me orders placed by jane.doe@example.com", "input_variables": {}}'AI agent — streamed (-N shows events as they arrive):
curl -sS -N -X POST "$BASE/agents/$AGENT_ID/stream" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"message": "fetch me orders placed by jane.doe@example.com", "input_variables": {}}'AI agent — conversation. Start with session_id: null, then send back the sessionId from the response as session_id on each next message:
curl -sS -X POST "$BASE/agents/$AGENT_ID/conversation" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"message": "Hello", "session_id": null, "input_variables": {}}'Task Agent — a run’s result and trace (run ID from the run response or GET /agents/{id}/runs):
curl -sS "$BASE/agents/$AGENT_ID/runs/$RUN_ID" -H "Authorization: Bearer $KEY"
curl -sS -N "$BASE/agents/$AGENT_ID/runs/$RUN_ID/stream" -H "Authorization: Bearer $KEY"Multi-agent system:
curl -sS -N -X POST "$BASE/mas/$SYSTEM_ID/conversation/stream" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"message": "A customer is unhappy that their order is late", "input_variables": {}}'Key reference
| Field | Required | Meaning |
|---|---|---|
name | Yes | Label shown in logs and as the caller of every run |
key | key or key_sha256 | The key, starting qr_ |
key_sha256 | key or key_sha256 | The key’s SHA-256 instead, so the secret isn’t stored on disk: printf %s 'qr_…' | sha256sum |
scopes | Yes | What the key may do (below) |
restrict_to | No | Agent and system IDs the key may call. Omit for all exposed ones |
roles | No | Roles the key acts with. Agents that require a role check them, and data governance rules for those roles apply |
expires_at | No | When the key stops working, e.g. "2027-01-31T00:00:00Z" |
enabled | No | false turns the key off. Defaults to true |
| Scope | Allows |
|---|---|
invoke | Running agents, streaming, conversations, multi-agent system messages |
runs:read | Run lists, results and traces |
runs:write | Cancelling runs |
info:read | Agent details and a system’s member skills |
hil:resume | Resuming a run paused at a HIL node |
conversations:read, conversations:write | Reading and managing conversation history |
"*" | All of the above (quote it in YAML) |
Each key acts as its own caller — runs one key starts aren’t visible to another.
Rotate and revoke
Changes apply within about 5 seconds of saving.
- Revoke — delete the key’s entry, or set
enabled: false. - Rotate — add the new key, move your integration to it, then delete the old one.
- Close an agent — set its
enabled: falseunderexposed. Every key loses access at once.
If a save leaves the file with a YAML error, the gateway keeps the last working version and logs appliance_api_keys_unparseable. A revocation in a broken file hasn’t taken effect — after revoking, confirm appliance_api_keys_loaded appears.
config/api-keys.yaml isn’t part of the signed bundle, so editing it never fails verification. Keep a copy when you upgrade: unpacking a new archive writes a fresh, all-off file.
Troubleshooting
| Response | Cause | Fix |
|---|---|---|
401 Invalid API key | No entry matches the key | Check the value. A new shell loses $KEY |
401 API key disabled / API key expired | enabled: false, or expires_at has passed | Re-enable it or issue a new key |
403 … is not exposed for API access | The agent’s enabled is false under exposed | Set it to true |
403 This API key is not permitted to call this agent | The ID isn’t in the key’s restrict_to | Add it |
403 Insufficient API key scope: this endpoint requires '…' | The key lacks that scope | Add the scope |
403 API keys can only call agent, MAS and conversation endpoints | The key was used on an app route | Call the agent endpoints instead |
200, but the agent says a table doesn’t exist or finds no data | The key has no roles, so it acts as guest, which can’t read that table | Give the key a role that can |
200, but the answer says the model or provider failed | No model credentials in .env | Add them — see Connecting models — then docker compose up -d llm-proxy |
200, but the answer is an error | The agent itself failed | docker compose logs agent-runtime --since 5m |
| Nothing changes after saving | The file has a YAML error | docker compose logs gateway | grep appliance_api names it |