Skip to content

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 adds core.kind: service_account to every account, so a rule can single out all accounts at once, for example to keep every account away from HR dossiers.
  • PATCH replaces 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) to to (exclusive). Without either, it is the current calendar month in UTC, and an end you leave out is filled from that month. A to that is not after from is 422 on to.
  • Cost is priced at the prices in force when each turn ran; unpriced counts usage no price covered, counted as zero in cost_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 failed with service_account_paused.
  • Handing it work is 409 with reason_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