Agents and durable runs
Define governed agents, attach knowledge and tools, and execute bounded workflows.
Agents package a persona, model policy, optional knowledge, and tool grants. Use one-shot invocation for a single governed completion or create a durable run for multi-turn tool execution, approval, retry, and checkpointing.
Create an agent
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/agents" -d '{
"owner_type":"user",
"owner_id":"user-42",
"name":"Mailbox triage",
"description":"Find and summarize billing email.",
"persona":"Be concise. Never send email without explicit approval.",
"default_model":"gpt-5-mini",
"allowed_models":["gpt-5-mini"],
"temperature":0.2,
"tools":[
{"tool_name":"email.search","policy":"auto"},
{"tool_name":"email.send","policy":"requires_approval"}
]
}'Agents can belong to a user, team, or org. Creation and management
authority follows that owner.
Tool policy is either:
auto: the durable run may execute it;requires_approval: the run parks before execution.
policy is optional. Omit it and the default follows the tool's origin:
first-party tools default to auto, while tools from a registered MCP server
(mcp/<slug>/<tool>) default to requires_approval — that server is operated
by a third party and can change what a granted tool name does. Running one
unattended is possible, but you have to say "policy":"auto".
Agents never store provider or connection credentials. Tools resolve from the invoking owner's current connections.
Attach knowledge or tools
Replace the attached dataset set:
PUT /v1/agents/{id}/datasets
{"dataset_ids":["<dataset-id>"]}Attached datasets are caller-filtered at invocation time. The response reports attachments the caller cannot access rather than using agent ownership as a confused deputy.
Tool grants and datasets are mutually exclusive in the current one-shot retrieval design.
An agent with attached datasets cannot start a durable run. Retrieval is not
performed inside the checkpointed turn loop, so a run would have executed
without the agent's defining capability. POST /v1/agents/{id}/runs now answers
409 agent knowledge is not supported by durable runs; use invoke before any
side effect. No backing conversation, no run row, no job, no audit write, no
input screening, and no model spend happens. Use one-shot invocation, which keeps its
retrieval path unchanged, or replace the attached set with an empty one to make
the agent runnable again.
One-shot invocation
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/agents/$AGENT_ID/invoke" -d '{
"message":"Find invoices received this week.",
"model":"gpt-5-mini"
}'One-shot invocation can return tool calls but does not execute
requires_approval tools server-side. Use a durable run for managed execution.
Start a durable run
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/agents/$AGENT_ID/runs" -d '{
"message":"Find the latest invoice and draft a reply asking for line-item detail.",
"max_turns":8,
"credit_budget":500000
}'The response identifies an asynchronous run. Poll:
GET /v1/runs/{run_id}
GET /v1/runs?agent_id={agent_id}&status=running
GET /v1/runs?schedule_id={schedule_id}
GET /v1/runs?conversation_id={conversation_id}schedule_id filters to the fire history of one
scheduled agent task;
conversation_id filters to one thread of runs. Run
listing and run detail stay owner-only for every role, so an org-admin who may
administer a schedule still does not see its run content, and another person's
thread lists as an empty page rather than a refusal.
Each turn holds a database-time lease, performs model or tool work, and commits a durable checkpoint. Restarts and replica changes do not discard committed progress.
Choose which mailbox a run uses
The email.* tools are declared by both mail providers. When the caller has
Gmail and Outlook connected, the engine's default serves a call through the
longest-standing connection with providers in catalog order — Gmail. To have a
run read another mailbox, name the connection on either create route:
{
"message": "Read my flight itinerary and add the flight to my calendar.",
"model": "gpt-5-mini",
"connection_ids": ["<outlook-connection-id>"]
}Each id must be one of the caller's own active connections (GET /v1/connections); any other is 404. The run view echoes the list as
connection_ids, and a preferred connection that is no longer active falls back
to the default rather than failing the call. An empty list changes nothing.
Name the dataset or the project a run reads
dataset_id names the dataset the task reads through. It is optional on both
create routes, and the run view echoes it as dataset_id, null when the run
has none:
{
"message": "How many orders shipped late last quarter?",
"model": "gpt-5-mini",
"dataset_id": "<dataset-id>"
}The engine checks the id at create with the identity the RUN will execute
under, and that identity carries no roles. Read access that survives losing
the roles is: owning the dataset, a direct user grant on it, a team grant on
it, owning the project the dataset belongs to, or a user or team grant on that
project. A dataset shared only through a role — the ownerless organization
dataset with a role:org-member read grant — is therefore not usable as a
run's dataset, and is refused exactly like a dataset that does not exist.
Unknown, unreadable and malformed ids all answer 404, with no way to tell
them apart. The id is stored in canonical form, so any accepted spelling comes
back as the canonical 36-character lowercase uuid.
project_id names a project instead, and a task started in one reads the
whole of it — the project's own files plus the datasets attached to it:
{
"message": "How many orders shipped late last quarter?",
"model": "gpt-5-mini",
"project_id": "<project-id>"
}The two fields are mutually exclusive: sending both is 400 project_id and dataset_id are mutually exclusive. A project is a set of datasets, so naming
one of each says two different things about what the run reads. The project is
checked exactly as the dataset is — at create, with the roleless identity the
run executes under, so a project shared only through a role:org-member grant
is not usable — and unknown, unreadable and malformed all answer 404 project not found.
Unlike dataset_id, the project belongs to the run's thread rather than
to the run row. It is written on the conversation the run opens, so:
- the run view echoes it as
project_id,nullwhen the thread is in no project; - a follow-up run (
conversation_id) inherits the thread's project and need not send anything. Naming a different one is400 a follow-up keeps its thread's project, and sendingdataset_idon a follow-up whose thread is in a project is400 a follow-up cannot name a dataset: its thread is in a project; - a schedule's
project_idis written on every fired run's conversation, so every run of a scheduled task reads the same project.
Placing a task in a project also files its thread there: the conversation appears wherever that project's conversations do.
Query a dataset's tables
dataset.query is an engine tool: the engine executes it, not a connection
and not the runner. It answers a question about the tabular files in one
dataset — CSV files and Excel sheets, one table per file or sheet — from a
question in plain words, which the engine turns into a read-only DuckDB
SELECT, or from a statement you write yourself. One query reads one table;
a run started with project_id may reach any table in the project — across
at most ten of its datasets, the same ceiling a project's scope carries
everywhere else — and the engine records which dataset the table was found
in.
A run is granted the tool automatically in exactly one shape: a create
body that omits tools — POST /v1/runs, and the message + model
schedule shape. That runner is granted what its person can reach, and the
engine asks the same roleless identity the run executes under whether the
caller can read at least one dataset; a caller with no readable dataset is not
offered dataset.query at all, rather than being offered a tool whose every
call it would have to refuse. POST /v1/runs re-takes that reach on every
request; a schedule takes it once, when the schedule is created.
Both other shapes bypass the reach gate, and in both you must ask for the tool by name:
toolspresent. The runner is granted exactly the names you sent, at the catalog's default policy, and is never re-synced. Includedataset.queryamong them or the run cannot call it — an explicit[]is a runner with no tools at all.POST /v1/agents/{id}/runs. The named agent's own grants decide, sodataset.queryhas to be one of them (PUT /v1/agents/{id}/tools).
| Argument | What it is |
|---|---|
dataset | Optional. The dataset to read, by id or by its exact name, matched case-insensitively among the datasets the caller can read — a project's files are named by the project. Omitted, the run's own dataset_id is used; with neither, the run thread's project, if it has one; with none of the three the call is refused. |
table | The table to read. Optional with question — the engine picks the table the question fits — and required with sql. Without dataset, a project-scoped run resolves the name across every dataset of the project. |
question | What to compute, in plain words. |
sql | An explicit read-only SELECT over one table. |
max_rows | Optional. Omitted means 2000, which is also the cap; a larger value is cut to 2000. A value below 1 is refused by argument validation — the schema declares a minimum of 1 — rather than raised to anything. |
Exactly one of question and sql. A question is turned into a SELECT by
the engine, validated, and repaired once if the statement is rejected; an
explicit sql is validated and run as given, never repaired, and must name
its table. Writing the statement is a model call like any other: it is
billed to the organization and counted in the run's credits.
The model is shown a preview, not the whole capture:
{
"query_id": "q_9f2c1ab40e57",
"table": "orders",
"columns": ["region", "shipped_late"],
"preview_rows": [["EMEA", 412], ["AMER", 233]],
"preview_truncated": false,
"capture_rows": 2,
"total_rows": 2,
"truncated": false,
"usage": {"input_tokens": 1180, "output_tokens": 64}
}preview_rows holds at most 100 rows and is bounded by bytes as well, so a
wide table is cut here — preview_truncated says so — rather than losing its
query_id to the run's tool-result cap. capture_rows is how many rows the
stored capture holds, truncated whether that capture holds fewer than the
statement produced, and total_rows is null when the engine could not count
them.
The whole capture is stored on the engine's side, but no route returns it
today: query_id is the handle a later surface will bind to, not something
an integrator can read back, so there is no endpoint to go looking for.
A refusal comes back as a tool result rather than an error, so the model can act on it and ask again:
{"error": "no dataset \"files\" you can read; datasets you can read: Sales"}Both dataset refusals name what the caller could ask for instead: with
nothing to read at all it is this task has no project and no dataset; start it in a project, or name a dataset you can read: Sales, Support tickets, and
with a name it cannot read, the message above. When there is nothing to list,
the first becomes this task has no project and no dataset, and you have no dataset you can name: start it in a project, add a CSV or Excel file to a project's files or datasets, or rename any that share a name, and the second
ends with the same you have no dataset you can name — … clause.
A run started with project_id is told about tables instead, since its
caller named no dataset and may not know one exists. The tables are listed
grouped by the dataset each is in, with the project's own files written
(files):
no table "invoices" in project "Quarterly review"; tables you can read: sales, orders ("Quarterly review"), regions ("Geo")— bounded to ten, with a trailing…when there are more. A project's own files are named by the project, which is what its hidden store is called;table "sales" is in more than one of the project's datasets: "Quarterly review" (files), "Geo"; name the dataset too— senddatasetas well astable. Every dataset either sentence names is one thedatasetargument resolves to that very dataset; one whose name would reach a different dataset you can read is left unnamed rather than offered;project "Quarterly review" has no table you can read; datasets you can read: Sales— the project holds no CSV or spreadsheet this run can read.
A reference in a project that the caller cannot read in their own right is simply not in the scope: it is never read and never named. A project-scoped run therefore reaches no more than a chat in that project does.
The tool also refuses when sql arrives without table, when the statement
is not a single SELECT, when a generated statement could not be repaired,
when the query exceeded the sidecar's thirty-second budget — ask a narrower
one — when the dataset holds no tables at all (dataset: has no tables),
when the named table is not one of that dataset's tables, and when that name
matches two of its documents, which a re-ingested dataset can hold when two
generations carry one sheet name and neither is authoritative.
One refusal is worth reading for its remedy rather than its cause: when two
datasets the caller can read share the name given, the tool answers more than one dataset is called Orders; name it by id. Send the dataset's id in
dataset — or start the task with dataset_id — instead of the name.
Repeating the same call in the same place of the same run answers from what was stored rather than querying again. Change the arguments and it is a fresh query.
Continue a thread
Every run answers with a conversation_id. Send it back when you create the
next run and that run joins the same conversation instead of opening one of
its own: the earlier runs' messages, tool calls and tool results are the new
run's history, so a follow-up can act on what an earlier run read without
reading it again.
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/runs" -d '{
"message":"Now add that flight to my calendar.",
"model":"gpt-5-mini",
"conversation_id":"'"$CONVERSATION_ID"'"
}'Both create routes take the field. The conversation must be one of your own
that a run opened: another person's, another organization's or a deleted one
is 404, a plain chat conversation is 400, and a follow-up while a run on
the thread is still queued, running or awaiting_approval is 409 — one
run at a time on a thread.
GET /v1/runs?conversation_id={conversation_id} lists the thread's runs,
newest first, paged like the rest of the listing; reverse the page to read the
thread in order.
Handle approval
When status is awaiting_approval, inspect the pending action in the owner-only
run view, then:
curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/runs/$RUN_ID/approve" \
-d '{"note":"The draft is safe to send."}'Or deny:
POST /v1/runs/{run_id}/deny
{"note":"Do not contact this recipient."}Approval, state transition, and continuation-job creation commit atomically. Denial becomes a tool result so the model can adapt.
Bounds and cancellation
Every run is bounded by:
- maximum turns;
- an optional micro-USD credit budget;
- a wall-clock deadline;
- an approval timeout;
- a maximum tool-result size.
Cancel with:
POST /v1/runs/{run_id}/cancelCancellation stops after in-flight work completes. Explicit terminal reasons include budget, turn, timeout, guardrail, tool, and cancellation outcomes.
Events
Runs emit content-free events including run.started,
run.approval_required, run.tool_unavailable, run.succeeded, and
run.failed. The shared webhook delivery state machine can deliver them without
placing prompts, tool arguments, or results in the event payload.