Browse documentation
Runs API
Start, continue, stream, inspect, cancel, and delete durable agent runs.
View MarkdownThe Runs API is the application-facing execution API. A run keeps the pinned agent version, trusted Pi session, and durable workspace across turns.
Start a run
POST /api/v1/runsRequired scope: run:write.
curl "$SALAMBO_BASE_URL/api/v1/runs" \
-H "Authorization: Bearer $SALAMBO_API_KEY" \
-H "Idempotency-Key: start-report-001" \
-H "Content-Type: application/json" \
-d '{
"agent": "agt_01J...",
"input": "Write a short status report.",
"background": true
}'Supply exactly one selector:
agentstarts a new run, optionally on an exact version withversion;run_idcontinues an existing run.
The default delivery waits for the affected turn. Set background to return
after durable admission, or set stream to receive Server-Sent Events. Do not
set both fields to true.
In the TypeScript SDK, runs.execute waits for the turn unless background is
true, and runs.stream receives the events. The SDK sends an idempotency key
for you, or use the one you pass:
const run = await salambo.runs.execute(
{
agent: 'agt_01J...',
input: 'Write a short status report.',
background: true,
},
{ idempotencyKey: 'start-report-001' },
);
const stream = await salambo.runs.stream({
agent: 'agt_01J...',
input: 'Write a short status report.',
});
for await (const event of stream) {
console.log(event.sequence, event.type);
}
const result = await stream.finalResponse();Start on an exact agent version
A new run starts on the agent's active deployment. Add version to start it on
exactly that deployment version instead:
{
"agent": "agt_01J...",
"version": 3,
"input": "Qualify Acme"
}The version is the number every run reports as agent_version. Starting on it
does not activate it, so other runs of the agent keep using the active version.
To compare versions on the same tasks, start one run per version and read the
agent_version each response reports to confirm what executed it.
versiononly goes withagent. Sending it withrun_idis a400. A run started on an exact version runs its first turn on that version. Later turns follow the workspace-upgrade policy of the agent's active deployment:automaticmoves the run to the active version before its next turn, andnew-run-onlykeeps it where it is. Each turn reports the version that executed it.- The version must belong to the agent and be ready to run.
- Retrying with the same
Idempotency-Keyreturns the same turn on the same version, even if the agent activates another version in between. Reusing the key for a different version is a409 idempotency_conflict.
Errors that name version in error.param:
| Status | Code | Meaning |
|---|---|---|
400 | invalid_request | version is not a positive whole number, or it is sent with run_id |
404 | agent_version_not_found | The agent has never had this version |
409 | agent_version_not_ready | The version exists but is not ready to run, for example it is building |
An unknown agent, or an agent of another workspace, is still 404 agent_not_found
whatever the version.
In the TypeScript SDK, version goes beside agent. The types reject it beside
run_id, and the SDK throws before sending a call that mixes the selectors. The
response is the run object as the API returns it, so agent_version is the
version that executed the turn. Pass idempotencyKey to repeat a start safely:
const run = await salambo.runs.execute(
{ agent: 'agt_01J...', version: 3, input: 'Qualify Acme' },
{ idempotencyKey: 'qualify-acme-v3' },
);
console.log(run.agent_version); // 3Tag a run
A tag is a plain string label you choose, such as regression or
customer-reported, so you can find the run again. Tags are free-form: there is
no list of allowed values and no naming format, and Salambo never interprets a
tag. scenario=abc is just the text scenario=abc. Tags belong to the run.
Add tags when you start a run:
{
"agent": "agt_01J...",
"input": "Qualify Acme",
"tags": ["regression", "customer-reported"]
}- A tag is 1 to 64 characters and is stored exactly as you send it. It is
case-sensitive, so
Regressionandregressionare two tags, and nothing is trimmed, so" a "keeps its spaces. - A tag that is empty, only whitespace, or holds a control character (a tab or a
line break, for example) is a
400witherror.paramset totags. - A request names at most 20 tags and a run has at most 20. A repeated tag is one tag.
tagsonly goes withagent. Sending it withrun_idis a400namingtags. To change the tags of a run that exists, use the call below.- Retrying with the same
Idempotency-Keyand the same tags returns the same run. The order of the tags does not matter. Reusing the key with other tags is a409 idempotency_conflict.
In the TypeScript SDK, tags goes beside agent. The types reject it beside
run_id, and the SDK throws before sending a call that mixes the selectors:
const run = await salambo.runs.execute({
agent: 'agt_01J...',
input: 'Qualify Acme',
tags: ['regression', 'customer-reported'],
});Every run and every listed run carries its tags, sorted, as a list that is empty when it has none:
{
"id": "run_01J...",
"object": "run",
"tags": ["customer-reported", "regression"]
}The SDK returns the list as the API sends it, on the run that execute,
retrieve and list return:
console.log(run.tags); // ['customer-reported', 'regression']Change a run's tags
POST /api/v1/runs/{runId}/tagsRequired scope: run:write.
{ "add": ["triaged"], "remove": ["needs-triage"] }The response is the run's full tag list:
{
"object": "run_tags",
"run_id": "run_01J...",
"tags": ["regression", "triaged"]
}Adding and removing are set operations. Adding a tag the run already has, or
removing one it does not have, changes nothing, so a repeated request is safe
without an Idempotency-Key, and two callers editing one run never erase each
other's tags.
- Supply at least one of
addandremove, and no tag in both: that is a400. - The limit of 20 applies to the result. A request may add tags while it removes
others, and a result over 20 is a
400namingaddthat changes nothing. - An unknown run, or a run of another workspace, is
404, and an expired run is410 run_expired.
Changing tags never changes the run's turns, their inputs and outputs, the deployment version they ran on, or work in progress, and it does not keep an idle run retained for longer.
In the TypeScript SDK, runs.tags.update takes the lists and returns the run's
full tag list. Give at least one of add and remove: the types require it, and
the SDK throws before sending a call that gives neither. A repeated call is safe,
so the SDK sends no idempotency key.
const result = await salambo.runs.tags.update('run_01J...', {
add: ['triaged'],
remove: ['needs-triage'],
});
console.log(result.tags); // ['regression', 'triaged']A tag the API refuses is thrown as an InvalidRunTagsError, a SalamboApiError
whose param names tags, add, remove or tag:
import { InvalidRunTagsError } from '@salambo-ai/sdk';
try {
await salambo.runs.tags.update('run_01J...', { add: [''] });
} catch (error) {
if (!(error instanceof InvalidRunTagsError)) throw error;
console.log(error.param); // 'add'
}Wait for a turn
By default POST /api/v1/runs waits for the affected turn, for up to 120
seconds. If the turn is final by then the answer is 200 with the run. If it is
not, the answer is 202 with the run as it stands and a Location header naming
it. A 202 means the request stopped waiting. The turn did not stop, and
nothing was started twice. Set background to get the answer as soon as the
turn is admitted. The turn in a 202 is usually still running, but it can
already be final, because a turn admitted in the background can finish before
the API reads it back. Check run.turn.status and do not assume there is
something to wait for.
Either way, the way to the result is to read the turn until its status is
final. completed, failed, cancelled and incomplete never change. Until
then it is queued, in_progress or cancelling:
GET /api/v1/runs/{runId}?turn_id=turn_...Name the turn with turn_id. Without it the endpoint selects the latest turn,
and a turn added to the run later would answer for the one you were waiting on.
In the TypeScript SDK, runs.execute does this itself: on a 202 it goes on
with runs.wait, so it returns a run whose turn is final unless background
is true. Use runs.wait to finish a background start:
const started = await salambo.runs.execute({
agent: 'agt_01J...',
input: 'Reconcile these invoices.',
background: true,
});
const run = await salambo.runs.wait(started.id, {
turn_id: started.turn.id,
signal: AbortSignal.timeout(10 * 60_000),
});What to know before you rely on it:
- A failed turn is a result.
waitresolves for every final status and does not throw forfailed,cancelledorincomplete. Readrun.turn.statusandrun.turn.error. - There is no deadline. A turn that never settles is waited on for ever.
Pass
signal: AbortSignal.timeout(ms)to set one. - Aborting a wait does not cancel the turn. It stops reading. To stop the work, cancel it.
- Reads are spaced out. The first wait is
pollIntervalMs(default 1000 milliseconds), each one after it is half as long again, up to 10 seconds. Every wait is then made up to 20% longer at random, and none is shorter than its interval. - A
429does not end the wait. The SDK waits as long as theRetry-Afterthe API sent says, or the next interval if there was none, and reads again. Any other error ends it. Reading is rate limited like every other request, so a client that polls its own way should back off the same way.
To watch a turn while it runs, stream it. To read what it did afterwards, use turn history and retained events.
Continue a run
Use the same endpoint with the existing run ID:
{
"run_id": "run_01J...",
"input": "Revise the report for an executive audience.",
"on_busy": "queue"
}on_busy accepts:
| Value | Behavior |
|---|---|
queue | Admit one bounded follow-up turn after active work. |
steer | Add the input to the active turn when steering is possible. |
reject | Return a conflict when the run is busy. |
If the active turn ends before a steer input is stored, the call is a 409
with code steering_target_ended:
| Status | Code | Meaning |
|---|---|---|
409 | steering_target_ended | The turn this input was sent to has ended. Send it again with a new Idempotency-Key. |
The input was not taken, and retryable is false: the same Idempotency-Key
returns the same answer, because it names the admission that was refused. A new
key is a new request.
In the TypeScript SDK, pass run_id instead of agent:
const next = await salambo.runs.execute({
run_id: 'run_01J...',
input: 'Revise the report for an executive audience.',
on_busy: 'queue',
});Send text and files
input can be a string or an array of typed parts:
{
"run_id": "run_01J...",
"input": [
{ "type": "input_text", "text": "Review this invoice." },
{ "type": "input_file", "file_id": "file_01J..." }
]
}Upload larger input files through POST /api/v1/files. Small files can be sent
inline with filename and base64 file_data.
In the TypeScript SDK, files.upload returns the file to reference, and
input takes the same typed parts:
const file = await salambo.files.upload({
file: readableStream,
filename: 'invoice.pdf',
media_type: 'application/pdf',
});
const run = await salambo.runs.execute({
run_id: 'run_01J...',
input: [
{ type: 'input_text', text: 'Review this invoice.' },
{ type: 'input_file', file_id: file.id },
],
});Read run and turn state
GET /api/v1/runs/{runId}
GET /api/v1/runs/{runId}?turn_id=turn_...Required scope: run:read.
Without turn_id, the endpoint selects the latest turn. The returned object
contains the run state and the selected turn. A run that has expired returns
410 with code run_expired:
{
"id": "run_01J...",
"object": "run",
"agent_id": "agt_01J...",
"agent_version": 7,
"state": "idle",
"disposition": "created_run",
"tags": ["regression"],
"turn": {
"id": "turn_01J...",
"object": "turn",
"status": "completed",
"agent_version": 7,
"input": [],
"output": [{ "type": "output_text", "text": "..." }],
"error": null
}
}turn.agent_version is the deployment version of the agent that executes that
turn. It is pinned when the turn is admitted and never changes, so it still names
the version that ran after the agent's active version moves on. One run can hold
turns on different versions, so read it from the turn you care about. The
run-level agent_version equals the selected turn's.
The TypeScript SDK returns this object as the API sends it:
const run = await salambo.runs.retrieve('run_01J...', {
turn_id: 'turn_01J...',
});
console.log(run.turn.agent_version);List runs
GET /api/v1/runsRequired scope: run:read.
curl "$SALAMBO_BASE_URL/api/v1/runs?agent_id=agt_01J...&version=7&limit=20" \
-H "Authorization: Bearer $SALAMBO_API_KEY"The list is ordered by most recent activity. Filters combine:
| Query parameter | Selects |
|---|---|
agent_id | Runs of one agent |
version | Runs with at least one turn on that deployment version |
state | idle or busy runs |
tag | Runs with this tag. Repeat it, up to 5 times, for runs with all of them |
created_after | Runs created at or after a Unix timestamp |
created_before | Runs created at or before a Unix timestamp |
Repeating tag narrows the list: ?tag=regression&tag=customer-reported lists
only runs that have both. A tag matches exactly, with its case, and the filter
uses the same cursor pagination as any other. More than 5 tags, or a tag that
could not be a tag, is a 400 naming tag.
A filter of tags that are all common in your workspace searches at most 50,000
of its runs to fill a page. If that does not fill one, the request is refused
with 400, code invalid_request and param tag, and never answered with a
short page, which would read as the end of the list. Add a more specific tag:
a run must have every tag, so a tag that few runs have narrows the search.
In the TypeScript SDK, tag takes one tag or an array of them, and sends each as
its own tag parameter:
// Runs that have both tags
const page = await salambo.runs.list({
tag: ['regression', 'customer-reported'],
});A run that moved to a newer agent version still lists under the versions its
earlier turns ran on, so it can appear under more than one version. Its
listed agent_version is the version its workspace is on, which a queued turn
on a newer version may not have reached yet.
limit accepts 1 to 100 and defaults to 20. When has_more is true, pass
next_cursor as after to read the next page. Cursors are opaque.
Each listed run is a summary: its ID, agent, agent_version, state, tags and
timestamps. Retrieve a run to read its turns.
In the TypeScript SDK, runs.list returns one page. Iterating it with
for await reads further pages as the loop needs them and stops when has_more
is false. page.next_cursor is the cursor to pass as after.
const page = await salambo.runs.list({ agent_id: 'agt_01J...', version: 7 });
for await (const run of page) {
console.log(run.id, run.agent_version);
}Durable data and events
A run and its turns are durable data. Their status, input, output, error and
agent_version are stored with the run and stay readable through retrieval and
listing for as long as the run is retained, whether or not any events remain.
Retrieval and listing read only this data and never load events.
Events are technical history. They are stored separately, follow their own retention, and are meant for observing and debugging execution. Read the run object, not the events, for the result of a turn. It stays readable, output included, when the events are gone or cannot be read.
Read retained events
GET /api/v1/runs/{runId}/eventsThe event list contains public Pi events and Salambo platform events in run
sequence order. Use turn_id to select one turn, after to continue from an
event cursor, and limit to set the page size.
The full event stream is encrypted before Salambo writes it to S2. Postgres stores the bounded run and turn read model. It does not store the full event payload history.
Events are technical history for observing and debugging execution, such as which tools a turn called or how it recovered. They are not the conversation: read what each turn was asked and answered from turn history, which stays available after older events expire.
Each event carries id, sequence (its position in the run), source, type,
run_id, turn_id, created_at (Unix seconds) and data. A listed event is
the same object the live stream delivers, so a streamed event and its retained
copy share an id.
Events do not keep the images that tools and messages carry. Two things are replaced by a marker:
- An image part in a tool result or a message, such as a screenshot, appears in
dataas its metadata only: itsmimeType, its decoded size inbytes, thesha256of those bytes, andomittedset tonot_persisted. - An inline
data:image/...;base64,...URL inside a string field of an event is replaced in that string by[image omitted: image/png, 937500 bytes]: its media type and its decoded size. This is done to a whole field, such as the text of a tool result or of a final message.
The agent still sees the image, because only the event copy lacks it.
{
"type": "image",
"mimeType": "image/png",
"bytes": 937500,
"sha256": "9f2c…",
"omitted": "not_persisted"
}The text a model streams piece by piece (text_delta and toolcall_delta events) is
stored as the model wrote it, a data URL in it included. The final message of the
turn (message_end) is a whole field, so its data URLs are replaced in the event,
while the output of the turn keeps them.
A string too large for one stored event (about 675 KiB of JSON in an event) is
replaced by a string that says so: [truncated: 800000 bytes]. Any other value too
large for it is replaced by { "truncated": true } with its originalBytes and its
sha256. The limit is on the event as the database prints it, which is longer than
its JSON for numbers written with an exponent: a list of about 3,300 numbers like
1e-300, or 41,000 like 1e+21, is already too large, where a list of 330,000 small
integers is not.
In the TypeScript SDK, runs.events.list returns one page with the API's
history on it. Iterating the page reads further pages as the loop needs them,
nextPage() reads one at a time, and next_cursor is the cursor to pass as
after, from any process:
const page = await salambo.runs.events.list('run_01J...', { limit: 100 });
console.log(page.history); // { status: 'complete', retention_days: 14 }
for await (const event of page) {
console.log(event.sequence, event.created_at, event.type);
}History is a rolling window
Each run keeps the retention period its event streams were provisioned with, 14 days by default, and every list response returns it:
{
"object": "list",
"data": [],
"has_more": false,
"next_cursor": null,
"history": { "status": "partial", "retention_days": 14 }
}Events expire one by one as they age past the period, so older events can be
gone while newer ones remain. Salambo applies the window itself: an event older
than retention_days is not returned, even if it is still stored. An
entitlement change applies to runs created afterwards, and never to a run that
already exists. retention_days is null while a run has no event stream yet.
A read without after reports in history.status what it found:
| Status | Meaning |
|---|---|
complete | The events begin at the run's first event, or at the selected turn's first with turn_id |
partial | Older events expired. The page holds the events that remain, from the oldest retained one |
expired | Events were recorded and every one has expired. data is empty, and the response is still a 200 |
empty | No event has ever been recorded for the run |
A retained suffix is never presented as the whole history. A read with after
continues a page and reports null for status, because it says nothing about
where history starts.
In the TypeScript SDK the history is page.history. The pages that follow the
first carry status: null, so keep the first page's history if you need it:
const first = await salambo.runs.events.list('run_01J...');
if (first.history.status === 'partial') {
console.log(
`older events expired; ${first.history.retention_days} day window`,
);
}
const second = await first.nextPage();
console.log(second?.history.status); // nullCursors and errors
A cursor names one event, and a read resumes after it, so that event has to be
retained. When it has expired, whether S2 deleted it or only the window passed
it, the request returns 410 with code event_cursor_expired, even if the
next event survives. Read again without after to start from what remains.
| Status | Code | Meaning |
|---|---|---|
410 | event_cursor_expired | The event after names is no longer retained |
503 | event_boundary_pending | The first event of an admitted turn is not recorded yet. Retry |
503 | event_history_unavailable | The history service could not be read, or a stream that held events is missing. Retry |
Both 503 responses are retryable. Neither is ever reported as empty or
expired: an outage is not evidence that history is gone.
The TypeScript SDK throws each of these as its own class, so you can recognise
them with instanceof. Every one is still a SalamboApiError with status,
code, param and retryable:
| Code | SDK class |
|---|---|
event_cursor_expired | RunEventCursorExpiredError |
event_boundary_pending | RunEventBoundaryPendingError |
event_history_unavailable | RunEventHistoryUnavailableError |
The SDK never answers an expired cursor by reading again without it, because that would hand you the retained events as if nothing had been lost. It throws, and you decide, for example by reading what remains:
import { RunEventCursorExpiredError } from '@salambo-ai/sdk';
try {
await salambo.runs.events.list('run_01J...', { after: savedCursor });
} catch (error) {
if (!(error instanceof RunEventCursorExpiredError)) throw error;
const retained = await salambo.runs.events.list('run_01J...');
console.log(retained.history.status); // 'partial', or 'expired'
}The same holds while paging, and for a stream that reconnects. A stream
reconnect resumes after the last event it received, and if that event has
expired the iteration and finalResponse() reject with the same error instead
of skipping ahead. The SDK retries a 503 itself, so one that reaches you
survived its retries and is worth retrying later.
Consumers must accept unknown event types and additive data fields. Use
source and type as discriminators.
Read turn history
GET /api/v1/runs/{runId}/turns
GET /api/v1/runs/{runId}/turns/{turnId}Required scope: run:read.
Turn history is the durable conversation record of a run. Use it to fetch a transcript: what each turn was asked, what it answered, which agent version ran it, and how it ended.
curl "$SALAMBO_BASE_URL/api/v1/runs/run_01J.../turns?limit=20" \
-H "Authorization: Bearer $SALAMBO_API_KEY"{
"object": "list",
"data": [
{
"id": "turn_01J...",
"object": "turn",
"status": "completed",
"agent_version": 7,
"input": [{ "type": "input_text", "text": "Summarize the findings." }],
"output": [{ "type": "output_text", "text": "..." }],
"created_at": 1786640500,
"started_at": 1786640502,
"completed_at": 1786640540,
"duration_ms": 38000,
"error": null
}
],
"has_more": true,
"next_cursor": "eyJ..."
}Turns are ordered oldest first, so the list reads as the conversation. A page
holds at most limit turns, from 1 to 100 with a default of 20. When has_more
is true, pass next_cursor as after to read the next page. Cursors are
opaque, and a cursor only works for the run that issued it: another run's cursor
returns 400 with code invalid_cursor.
Every turn carries its input and final output, its status, its error when
it failed or stopped incomplete, its timestamps, and agent_version, the
deployment version that executed it. Turns that are queued or running are
included with their current status, and their output is final once they finish.
GET /api/v1/runs/{runId}/turns/{turnId} returns one such turn by itself, where
GET /api/v1/runs/{runId}?turn_id=... returns the run state around a turn.
Turn history is read from records stored with the run. It stays available for as
long as the run is retained, and an expired run returns 410 with code
run_expired. It does not use the technical event log, so it keeps working after
older events expire. Events are rolling technical history, described under Read
retained events, and are not the record of a conversation.
The TypeScript SDK reads turn history with runs.turns. list returns one page
of turns and, like runs.list, iterates further pages lazily and exposes
next_cursor. retrieve returns one turn.
const history = await salambo.runs.turns.list('run_01J...', { limit: 20 });
for await (const turn of history) {
console.log(turn.agent_version, turn.status);
}
const turn = await salambo.runs.turns.retrieve('run_01J...', 'turn_01J...');Cancel work
POST /api/v1/runs/{runId}/cancelUse an empty body to target the active turn, or the queued turn when nothing is
active. Supply turn_id to cancel one selected turn. Active cancellation
returns 202 while the runtime settles. A queued turn returns 200 once
cancelled. A turn that already finished, or an idle run, returns 200 with the
existing result unchanged. See Runs and turns.
In the TypeScript SDK, runs.cancel returns the run as runs.retrieve reads it.
The result is the turn's status, and the SDK adds no lifecycle of its own:
// The active turn, or the queued turn when nothing is active
const run = await salambo.runs.cancel('run_01J...');
// One selected turn
await salambo.runs.cancel('run_01J...', { turn_id: 'turn_01J...' });run.turn.status says which of the four results you got: cancelling for an
active turn, cancelled for a queued one, and the turn's own status for one that
had already finished or an idle run. A finished turn is never relabelled
cancelled, so a failed or incomplete turn keeps its terminal reason in
run.turn.error. To observe an active turn settle, wait for it as for any turn:
const cancelling = await salambo.runs.cancel('run_01J...');
const settled = await salambo.runs.wait('run_01J...', {
turn_id: cancelling.turn.id,
});
console.log(settled.turn.status); // 'cancelled'Cancelling is idempotent. The SDK sends the idempotencyKey you pass, or makes
one per call and reuses it for its own retries. A repeated call with the same key
returns the turn it first selected, never a later one:
await salambo.runs.cancel(
'run_01J...',
{},
{ idempotencyKey: 'stop-report-001' },
);Aborting the signal of runs.execute or runs.wait stops your call and does
not cancel the turn: cancelling is only this call. An unknown run or turn is
404 (run_not_found, turn_not_found), and an expired run is
410 run_expired.
Delete an idle run
DELETE /api/v1/runs/{runId}Deletion fences new work and starts asynchronous cleanup. A busy run must be cancelled and allowed to settle before deletion.
await salambo.runs.delete('run_01J...');Idempotency
All mutating endpoints require an Idempotency-Key header, except
changing a run's tags, which is safe to repeat without
one. Reuse a key only when you retry the same operation.