Service accounts through the API¶
The concept behind agents is explained in Agents.
A service account is an identity your organisation gives an agent so it can work without a person present: on a schedule, when an event arrives, or when your application hands it work. On this page you create an account, decide who may hand it work, give it standing work, read what it did, and pause or delete it.
An account has a name, the agent it runs, attributes your organisation sets, one
API key of its own and a reserved subject id, sa:<name>. Everything the account does is recorded
for that subject id and decided under its attributes, also when nobody started it. Take the account
acme-planner, which runs the agent planner with department: ["planning"]. A schedule fires the
agent every Monday morning. The turn runs for sa:acme-planner with department: planning, so a
rule your organisation wrote over department lets it read the planning sources and not the HR
ones.
The examples use three keys:
| Variable | Key | Used for |
|---|---|---|
$CORE_KEY |
a key of your organisation holding management |
creating, changing, pausing and reading accounts |
$ACCOUNT_KEY |
the account's own key, returned once when you create the account | the account's standing work, such as its schedules |
$APP_KEY |
the key of your application's backend, on the account's instruct list | handing the account work |
Creating, reading, changing, pausing and deleting accounts needs a key holding management; a
key without it gets 404. Handing an account work needs a place on the account's instruct list
instead. The result of a hand-over can be read by the key that handed it over, by a key holding
management and by the account's own key. A
subject token is refused on every
/service-accounts route, whichever key minted it.
Create an account¶
curl -s -X POST "$CORE_URL/service-accounts" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"name": "acme-planner",
"agent": "planner",
"description": "Plans the week for the planning department",
"attributes": {"department": ["planning"]},
"instructors": [{"key_id": "key_7Rb2..."}],
"capabilities": {"runs": {}, "threads": {}, "schedules": {}},
"monthly_cost_cap_eur": "50"}'
Core answers 201 with the account and, in secret, the secret of the account's key. This is the
only time the secret is shown; Core keeps only its hash. Store it where your backend keeps its
other keys.
| Field | What it does |
|---|---|
name |
unique in your organisation: lowercase letters, digits and hyphens, at most 63 characters. Fixed once written, because the subject id sa:<name> is made from it |
agent |
the name of the stored agent the account runs. Your organisation must have an agent of that name that the account's key may use, or the create is 422 on agent |
description |
what the account is for |
attributes |
the attributes Core applies to everything the account does (Attributes) |
instructors |
who may hand the account work besides management (Who may hand the account work) |
capabilities |
the capabilities the account's key enables, as on any key (The account's key) |
policy |
the policy document the account's key works under |
monthly_cost_cap_eur, monthly_cost_cap_until |
the account key's monthly cap, as on any key (Monthly cap) |
The answer also carries id, subject_id (sa:acme-planner), key_id, status (active) and the
mailbox settings. GET /service-accounts lists your accounts, a page at a time
(Requests), and GET /service-accounts/{id}
reads one.
PATCH /service-accounts/{id} changes the agent, the description, the attributes, the instruct list
and the account's own mailbox. Fields you leave out stay as they are; the name never changes. When
you change the agent, the account key's rule that permits the agent follows.
The account can have a mailbox of its own that Core watches for new mail and sends from. You set it
with mailbox, mail_watch and mail_send on the same PATCH, after an administrator has
connected the mailbox for the account (A person's own mailbox).
Attributes¶
An account's attributes work like the attributes your application sends for a person in
X-Core-Subject (Send a subject on a turn):
your rules compare them with the labels on models, tools, knowledge sources and other resources. The
difference is where they come from. A person's attributes arrive with each request. An account's
attributes are stored on the account, and Core applies them to every piece of the account's work:
each call its key makes, each schedule fire, each trigger start, each round of a background run.
- A value is a string or a list of strings;
"department": "planning"is read as one value. - Each name and value must fit your organisation's vocabulary when it has one.
- Names beginning with
core.belong to Core and are refused. Core addscore.kind: service_accountto every account, so a rule can single out all accounts at once, for example to keep every account away from HR dossiers. PATCHreplaces the attributes as a whole. Work that is already running picks up the new values at its next round.
The account's key¶
The account owns exactly one key. It is an ordinary key: you send it as a bearer token, set its capabilities, policy and monthly cap, and rotate it as you would any other. A few things are different.
Two capabilities are refused. management and subjects are 422 on an account's key. An
account that could manage the organisation could edit its own attributes and instruct list, and one
that could mint subject tokens could act for people. Give the key the capabilities the account's
work needs: runs and threads for background turns, which handed-over work always is, and
schedules when it keeps its own schedules.
The key runs only the account's agent. Core writes one rule into the key's policy that permits
the account's agent and sets settings.agents_required. Every turn on the key must therefore name
the agent and run it as defined: a turn that adds its own tools, skills, instructions or prompt is
403 (Call an agent). A policy update that drops the rule or the
setting is 422 on policy.
The key acts only as the account. Every request with the account's key is for sa:<name>,
whether it names a subject or not. A request that names another person in X-Core-Subject or in
user is 403. A header that names the account itself may not carry attributes (422), because
the account's attributes are the stored ones.
No other credential can act as the account. Only the account's own key may name a subject id
beginning with sa:. Any other key that names one, in X-Core-Subject, in user, or in a
schedule's subject_id, gets 403. The same applies to minting a subject token for an account. To
start work for an account, use its key, or hand it work as described below.
Pause and delete go through the account. The key routes refuse to disable, enable or delete an
account's key with 409. Pause, resume or delete the account instead (below),
so the key and the account always agree.
Give it standing work¶
Standing work is work the account does on its own: schedules, and the event triggers and webhooks
that start workflows. Write it with the account's own key. Work
written that way fires for sa:<name>, with the account's attributes as they are at the moment it
fires, and is paid from the account key's budget.
A planning overview on the first Monday of every month at nine:
curl -s -X POST "$CORE_URL/schedules" \
-H "Authorization: Bearer $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d '{"name": "monthly-planning",
"cron": "0 9 * * 1#1",
"timezone": "Europe/Amsterdam",
"request": {"agent": {"id": "planner"},
"input": "Draft the planning for this month."}}'
The stored request names the account's agent, because the key requires one on every turn. You may
leave subject_id out: the fire runs for sa:acme-planner either way. 1#1 is the first Monday of
the month; What a cron expression means lists the other forms and how
to preview them.
Everything else about the schedule works as described in Schedules through the API:
its fires, its last_error, Run now, and the list of what it produced. While the account is paused,
each fire records failed with service_account_paused; the schedule stays enabled and fires again
at the first moment after you resume the account.
An administrator in the management console sets up the same standing work by choosing the account's key as the key the schedule runs as.
Who may hand the account work¶
Handing an account work, or instructing it, means queuing a turn or a workflow run that then runs
under the account's key, for sa:<name>, with the account's attributes. The person or application
that asked does not need to be able to reach what the account reaches.
Handed-over work runs with the account's rights, whoever asked for it. So whoever may instruct an account can get at anything the account can reach, including things they could not reach themselves. Put on the list only keys, or people, you would trust with the account's own rights.
instructors holds up to 32 entries. Each entry is one of:
| Entry | Who may instruct |
|---|---|
{"key_id": "key_7Rb2..."} |
every request made with that key, whichever person it names, if any |
{"key_id": "key_7Rb2...", "subject_id": "anna"} |
only requests made with that key that name the person anna |
A person on their own is never an entry. The key is the part of the request Core has verified; the person is what the key says, so an entry naming only a person would let any key claim to be her.
An empty list means that only management may instruct the account: keys holding management and
administrators of your organisation in the management console. They always may, because they can
edit the list anyway.
Each key on the list must be a key of your organisation that is not itself an account's key, and no
entry may name an account's subject id. An entry that breaks this is 422 on
instructors[<i>].key_id or instructors[<i>].subject_id. PATCH with instructors replaces the
whole list.
Hand it a turn¶
Your application's backend hands the account a turn with its own key, not the account's. The body is the body of
POST /responses:
curl -s -X POST "$CORE_URL/service-accounts/sva_4Kp9.../responses" \
-H "Authorization: Bearer $APP_KEY" -H "Content-Type: application/json" \
-H 'X-Core-Subject: {"id": "anna"}' \
-H "Idempotency-Key: plan-request-2026-10-23-anna" \
-d '{"input": "Anna asks for two days off next week; plan around it."}'
Core answers 201, a Location header and the response with "status": "queued". The turn always
runs as a stored background run under the account's key, for
sa:acme-planner; Core sets background and store and ignores stream and user. When the body
names no agent, the account's own agent runs.
The person in X-Core-Subject is the one asking: Core checks her against the instruct list and
records her as the instructor, beside your key. She is not the subject of the turn; the account is.
Send the same Idempotency-Key again and Core answers the turn it already queued instead of
queuing another.
| Status | What it means | What to do |
|---|---|---|
403 |
this key, or this key for this person, is not on the account's instruct list | add an entry, or instruct with a key that is on the list |
409 with reason_code: "service_account_paused" |
the account is paused | resume it first |
409 |
the account's key cannot act, because the account is being deleted | nothing; the account is going away |
404 |
your organisation has no account with this id | check the id |
Read the result at the address in Location:
curl -s "$CORE_URL/service-accounts/sva_4Kp9.../responses/resp_9Tm3..." \
-H "Authorization: Bearer $APP_KEY" -H 'X-Core-Subject: {"id": "anna"}'
It answers the response as GET /responses/{id}
does. It answers the key that handed the turn over, for the person it named, a key holding
management, and the account's own key. Every other caller gets 404.
Start a workflow run instead with POST /service-accounts/{id}/workflow_runs and a body
naming workflow_id, and optionally version, input, variables and labels. It is held to
the same instruct list and answers 202 with the run, which runs for sa:<name>. The account's
key must hold workflows beside runs and threads, so add "workflows": {} to its
capabilities first; without it the start is 422 on run_as_key_id. Follow it with
GET /workflow_runs/{id}.
List the account's runs¶
GET /service-accounts/{id}/responses lists the account's runs, newest first: every stored turn
that ran for sa:<name>, whether it was handed over, fired by a schedule, started with the
account's own key or run by a step of its workflow runs, as far as your key may see them under
your organisation's rules for threads. Use it to build a
work queue or an overview of what the account is doing, without keeping an index of your own.
curl -s "$CORE_URL/service-accounts/sva_4Kp9.../responses?status=requires_action&status=failed" \
-H "Authorization: Bearer $CORE_KEY"
The list needs a key holding management and threads; a key missing either gets 404. The
account's own key never holds management, so it gets 404 here. An administrator of your organisation reads the same list at
GET /tenants/{tenant_id}/service-accounts/{account_id}/responses.
| Parameter | Narrows the list to |
|---|---|
status |
runs in that state: queued, in_progress, requires_action, completed, failed, cancelled or incomplete. Repeat it to ask for several states at once |
schedule_id |
runs that schedule fired |
from |
runs created at or after this moment |
to |
runs created before this moment. Without from or to the list has no time limit on that side; a to that is not after from is 422 on to |
limit, cursor |
one page at a time (Requests) |
{"items": [
{"id": "resp_9Tm3...",
"status": "requires_action",
"created_at": "2026-10-23T08:14:02Z",
"completed_at": null,
"thread_id": "thr_2Hc8...",
"schedule_id": null,
"instructed_by_key_id": "key_7Rb2...",
"instructed_by_subject_id": "anna"}],
"next_cursor": null}
instructed_by_key_id and instructed_by_subject_id name who handed the run over; both are null
for work nobody handed over, such as a schedule's fire. schedule_id names the schedule that fired
the run, and is null for other runs and once that schedule is deleted. An item does not include the output: read a
run in full at GET /responses/{id}.
A run at requires_action is waiting for the result of a tool your application runs. Submit the
results with the account's key, as for any background run
(Submit tool results).
Open the trace of an answer¶
The account's turns write to threads like any other stored turn, and you read them with
GET /threads/{thread_id}/items. Each item a
turn wrote carries response_id, the id of the turn that wrote it. Use that id to list the model calls the turn
made (Read a trace):
curl -s "$CORE_URL/responses/resp_9Tm3.../trace" -H "Authorization: Bearer $CORE_KEY"
Each entry gives a call's id, timings, status and token usage, and the tools that call's answer
ran; it holds no prompt or answer text. To see what was sent to the model and what it answered,
open one call with GET /traces/{call_id}. Core records every such read in the audit trail.
An item your application appended itself has "response_id": null and has no trace. A key that
names no person reads the traces of the account's turns as it reads any turn of your organisation.
Read the overview¶
curl -s "$CORE_URL/service-accounts/sva_4Kp9.../summary?from=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $CORE_KEY"
{"service_account_id": "sva_4Kp9...",
"subject_id": "sa:acme-planner",
"from": "2026-10-01T00:00:00Z",
"to": "2026-11-01T00:00:00Z",
"turns": 42,
"cost_eur": "3.18",
"unpriced": 0,
"refusals": 1,
"mails_sent": 0,
"last_activity_at": "2026-10-22T07:00:04Z",
"by_instructor": [
{"instructed_by_key_id": null, "instructed_by_subject_id": null,
"turns": 30, "cost_eur": "2.40", "unpriced": 0, "refusals": 0,
"mails_sent": 0, "last_activity_at": "2026-10-22T07:00:04Z"},
{"instructed_by_key_id": "key_7Rb2...", "instructed_by_subject_id": "anna",
"turns": 12, "cost_eur": "0.78", "unpriced": 0, "refusals": 1,
"mails_sent": 0, "last_activity_at": "2026-10-21T14:12:40Z"}]}
It counts the account's turns, their cost, the refusals Core recorded for it, the mails its mailbox
sent and its last activity, in total and per instructor. A null instructor is work nobody handed
over: its schedules and triggers, and every mail the account sent. The read changes nothing.
- The window runs from
from(inclusive) toto(exclusive). Without either, it is the current calendar month in UTC, and an end you leave out is filled from that month. Atothat is not afterfromis422onto. - Cost is priced at the prices in force when each turn ran;
unpricedcounts usage no price covered, counted as zero incost_eur.
Pause and resume¶
curl -s -X POST "$CORE_URL/service-accounts/sva_4Kp9.../pause" \
-H "Authorization: Bearer $CORE_KEY"
Pausing stops the account at once. Use it when the key may have leaked or the agent is doing something it should not:
- Calls with the account's key are
401. - Its schedule fires and trigger starts record
failedwithservice_account_paused. - Handing it work is
409withreason_code: "service_account_paused". - Running work stops at its next round, and fails with
service_account_paused. A model call or tool call already under way finishes first.
POST /service-accounts/{id}/resume lifts the pause. Schedules fire again at their next moment;
fires missed in between are not made up for, and runs that failed with service_account_paused do
not restart. Both routes answer the account; asking for the state it already has changes nothing.
Delete an account¶
curl -s -X DELETE "$CORE_URL/service-accounts/sva_4Kp9..." \
-H "Authorization: Bearer $CORE_KEY"
Core answers 202 and the account's status becomes deleting. Its key is deleted at once, and
Core then erases the account's work the way it erases a person: schedules and triggers for it are
switched off, open workflow runs are cancelled, and its threads, responses, memory, files, datastore
records, traces and connections are deleted. Audit and usage records stay, with sa:<name> on
them, for their normal retention period. The account disappears once its work is gone; until then
you cannot create a new account with the same name.
If work was still running, the deletion waits. Delete the account again once that work has ended to
finish it. A legal hold on your organisation refuses the delete with 409 before anything changes.
To stop an account but keep what it did, pause it instead.
Common mistakes¶
| What you see | What happened |
|---|---|
404 on the account routes |
your key does not hold management, or the account is not your organisation's |
422 on agent |
your organisation has no agent of that name that the account's key may use |
422 on capabilities.management or capabilities.subjects |
an account's key may not hold either |
422 on attributes |
a name beginning with core., or a name or value outside your vocabulary |
422 on instructors[<i>].key_id |
the key is not one of your organisation's, or it is an account's key |
409 on create |
an account with that name exists, or is still being deleted |
403 on a turn with the account's key |
the turn names another person, or adds tools, skills or instructions to the agent |
403 naming sa:... with another key |
only the account's own key may act as the account |
409 on POST /keys/{id}/disable |
the key is an account's; pause the account instead |