Automations¶
An automation is a cron-driven inference run: a prompt template, a model, an optional input source, and something to do with the result. The desktop app calls them Automations, the Management API calls them schedules, and the CLI is aigo schedule. The three names refer to the same records, and this page uses "automation" for the concept and the API's own spelling for anything on the wire.
Seventeen endpoints make up the surface: fourteen under /schedules and three under /executions. Every one of them is reachable at the Management API base URL, http://127.0.0.1:8001/api/v1 by default, and appears in Swagger UI at /api/docs under the Schedules tag. The aigo schedule command group wraps the same endpoints; its flags are in the CLI reference.
How this surface behaves headless¶
Automations run on a headless server. aigo-server creates the schedule store, starts the scheduler loop, and installs the manager into the API state, so a schedule created over HTTP fires on its cron expression with no desktop app present. The Tauri commands and these endpoints are thin transports over the same service functions.
Automations have an event stream. GET /schedules/events provides SSE and GET /schedules/ws provides WebSocket, both carrying the run lifecycle and every configuration change under agent_read. The same events reach the cross-domain GET /events, which requires admin, and the desktop app additionally receives the Tauri event named schedule-event it has always received. A client that cannot hold a connection open polls GET /executions or GET /executions/{id} instead. A desktop app that also runs the embedded management API carries both, since issue #5040: every desktop subsystem emits through one shared sink, and that server attaches its event bus as a second surface while it is listening, so the desktop UI and the stream receive the same events. See Events.
Three behaviors differ on a headless server, and all three are capability limits rather than policy. notification output is written to the log rather than shown, because there is no notification service to show it on. autoLoadModel waits for the inference server to become ready rather than triggering a load, because no model loader is installed. And a scheduled run's tool calls are refused outright with "Tool execution is not available in headless scheduler runtime", so an automation that depends on enabledTools produces a run whose model gets a refusal back for every call. Set enabledTools on automations you intend to run headless only if the run is useful without them.
command input and saveToFile output run against the server's own filesystem and shell in both modes, not the caller's.
Authentication and scopes¶
Requests authenticate the same way as the rest of the Management API: an X-API-Key header, an Authorization: Bearer token, or the aigo_session cookie obtained from POST /api/v1/auth/login.
Access keys carry scopes, and this surface uses two:
agent_readfor everyGET.agent_writefor everyPOST,PUT,PATCH, andDELETE.
One mutating route is deliberately agent_read: POST /schedules/validate-cron parses an expression and stores nothing. The authoritative table is ROUTE_MANIFEST in src-tauri/crates/aigo-rest/src/route_scope.rs; the scope column below is copied from it.
Export is agent_read even though the document it returns carries webhook URLs and shell command lines verbatim. A key that can read automations can read the secrets inside them; see An export file is credential material.
In a managed install, an administrator can hide the Automations page with the /schedules page id in features.hiddenPages. That gate covers both the /schedules and /executions route prefixes and the matching Tauri commands, so hiding the page refuses the API too, not just the UI. Export is gated with the rest of the surface on purpose: a deployment that hides Automations must not leave a way to read their webhook URLs out over IPC. validate_cron and the notification-permission helpers are the exception on the IPC side, because other pages use them; the POST /schedules/validate-cron route is still gated by the prefix.
Resource model¶
Schedule¶
The stored automation. Its id is a UUID v4 the server mints on creation.
| Field | Type | Notes |
|---|---|---|
id | string | UUID v4. Server-assigned. |
name | string | Human-readable. Also the identity an import matches on. |
cronExpression | string | See Cron expressions and timezones. |
modelPath | string | Model file path or id the run infers against. |
agentId | string | Optional. Runs the automation as an agent profile. Omitted when unset. |
promptTemplate | string | Supports the variables in Prompt template variables. |
systemPrompt | string | Optional. Omitted when unset. |
inferenceParams | object | See Inference parameters. Always present. |
inputSource | object | Tagged. See Input source. Always present. |
outputAction | object | Tagged. See Output action. Always present. |
enabledTools | string[] | Optional. Absent means no tool calling at all. See Tool gating. |
toolPermissionOverrides | object | Optional. Tool name to permission token. |
autoLoadModel | boolean | Load the model before each run. |
unloadAfter | boolean | Unload it when the run finishes. |
catchUpMissed | boolean | Run occurrences missed while the process was down. |
enabled | boolean | Whether the scheduler loop considers it. |
timezone | string | IANA name. Defaults to UTC. |
createdAt, updatedAt | string | ISO 8601. |
lastRunAt, nextRunAt | string | ISO 8601. Omitted when the automation has never run, or when it is disabled and has no next occurrence. |
Optional fields are omitted rather than sent as null, so a client reading agentId gets either a string or nothing.
Execution¶
One run of one automation, created when the run starts and updated when it ends.
| Field | Type | Notes |
|---|---|---|
id | string | UUID v4. Usable on its own through GET /executions/{id}. |
scheduleId | string | The automation that produced it. |
status | string | pending, running, success, failed, skipped, or cancelled. |
startedAt | string | ISO 8601. |
completedAt | string | ISO 8601. Omitted while the run is still going. |
promptRendered | string | The prompt after template substitution. Omitted before it is built. |
result | string | The model's output text. Present on success. |
error | string | Why the run failed, was skipped, or was cancelled. |
tokensUsed | object | promptTokens, completionTokens, totalTokens. Omitted when the engine reported none. |
success, failed, skipped, and cancelled are terminal. skipped is a valid status value and ?status= filter token, but nothing currently sets it: a run that finds the model unavailable is recorded failed, and an occurrence that overlaps a still-running execution of the same automation produces no record at all.
History is per automation and capped at the 100 most recent records, so an execution id eventually stops resolving. Anything a run produced that has to outlive that belongs in a saveToFile or webhook output action.
Input source¶
Where {{input}} in the prompt template comes from. Adjacently tagged, with lowercase type names:
| JSON | Meaning |
|---|---|
{"type": "none"} | No external input. The template is used as written. This is the default. |
{"type": "file", "value": "/path/to/file"} | The file's contents. |
{"type": "directory", "value": "/data/*.md"} | Every file matching the glob, sorted by path and concatenated, each preceded by a --- <filename> --- header. There is no file-count limit, only the size cap below, and a glob matching nothing fails the run. |
{"type": "url", "value": "https://example.com/feed"} | The response body of an HTTP GET, with a 30-second timeout. Unlike a webhook output, a non-2xx response fails the run. |
{"type": "command", "value": "git -C /repo log -5"} | The standard output of a shell command. |
Input is capped at 100 KB and truncated above it. A url source accepts only http and https and applies the same host check the webhook output does, which reads the URL's host literally and does not resolve DNS. A command string is capped at 4096 bytes, and a command that has not returned after 60 seconds fails the run with a timeout, though the child process itself is not killed. A non-zero exit fails the run with the command's standard error. Paths containing .. are refused, and a directory glob stops adding files once the concatenation passes the cap.
The CLI spells these none, file:<PATH>, dir:<PATH>, url:<URL>, and command:<CMD>. Only the first colon separates the two halves, so a URL keeps its scheme.
Output action¶
What happens to the result. Adjacently tagged, with camelCase type names:
| JSON | Meaning |
|---|---|
{"type": "storeOnly"} | Keep it in the execution record only. This is the default. |
{"type": "saveToFile", "value": "/tmp/out.md"} | Write it to a file on the server's filesystem. |
{"type": "notification"} | Show a desktop notification. Desktop only. |
{"type": "webhook", "value": "https://hooks.example.com/x"} | POST it as JSON. |
The webhook body is {"scheduleName": "...", "result": "...", "timestamp": "<RFC 3339>"}. Only http and https are accepted, and the host is checked against localhost, private and reserved IPv4 ranges, loopback IPv6, and internal-hostname patterns. The check reads the URL's host literally and does not resolve DNS, so it does not stop a hostname that resolves into a private range. The request times out after 30 seconds. A non-2xx response is logged and does not fail the run.
The CLI spells these store, file:<PATH>, notify, and webhook:<URL>.
Inference parameters¶
| Field | Type | Default |
|---|---|---|
temperature | number | 0.7 |
maxTokens | number | 2048 |
topP | number | 0.9 |
stream | boolean | false |
The server replaces this block wholesale on an update, so a PUT carrying inferenceParams must carry every value it wants to keep. stream has no useful setting other than false: the executor collects a whole completion before it runs the output action.
Prompt template variables¶
promptTemplate is rendered before the request goes to the model. systemPrompt is sent verbatim, so template variables in it are not substituted:
| Variable | Substituted with |
|---|---|
{{input}} | The configured input source. |
{{date}} | The current date, YYYY-MM-DD. |
{{time}} | The current time, HH:MM:SS. |
{{datetime}} | The current ISO 8601 timestamp. |
{{file:/path/to/file}} | That file's contents, inline. |
{{env:VAR_NAME}} | An environment variable of the server process. |
{{prev_result}} | The result of the most recent successful run among this automation's last 10 execution records. |
{{env:...}} applies a case-insensitive prefix blocklist (TOKEN, SECRET, KEY, PASSWORD, AUTH, the common provider prefixes, and others), so a variable named TOKEN_FOR_X is refused while one named MY_TOKEN is not: do not rely on the blocklist to keep a credential out of a prompt. {{file:...}} is capped at 100 KB and its path is checked for traversal. A recognized variable that cannot be resolved (no input source, no previous result, an unreadable file, a blocked or unset environment variable) is replaced with an empty string and warned about; an unrecognized {{...}} placeholder is left in place. Neither fails the run. {{date}}, {{time}} and {{datetime}} read the server process's local clock, not UTC and not the automation's timezone.
Tool gating¶
enabledTools is an allowlist, not a hint. A scheduled run is unattended, so the executor denies any tool call whose canonical name is outside the list rather than letting the model reach a tool it was not advertised. Names are canonicalized on both sides, so an automation stored with a legacy alias still matches the tool it names.
toolPermissionOverrides maps a tool name to a permission token, and the values behave as follows on this path:
always_allow, and an absent override, execute, but only within the allowlist: an override never re-admits a toolenabledToolsdoes not name.never_allowdenies.ask_onceandask_alwaysdeny, because an unattended run has no approval surface to ask on. The denial is recorded rather than silently allowed and rather than blocking until the next occurrence overlaps it.
An unrecognized token counts as no override and warns. When the automation names an agentId and carries no overrides of its own, the agent profile's overrides apply.
Storage¶
Automations live in <app_data_dir>/schedules/schedules.json, and each automation's history in <app_data_dir>/schedules/executions/<schedule-id>/history.json. Writes go to a temporary file and are renamed into place.
Cron expressions and timezones¶
cronExpression accepts a standard 5-field expression (minute, hour, day of month, month, day of week), a 6-field expression whose extra leading field is seconds, a 7-field expression that also carries a year, or one of the presets @hourly, @daily (@midnight), @weekly, @monthly, and @yearly (@annually). Anything else is a 400.
timezone is an IANA name such as America/New_York and defaults to UTC. The expression is evaluated in that zone and nextRunAt is reported back in UTC, so an automation set to 0 9 * * * in Asia/Seoul reports a nextRunAt of midnight UTC.
The scheduler loop ticks every 30 seconds and runs an automation once its stored nextRunAt is at or before the tick, so a run starts within about half a minute after its slot rather than exactly on it. One automation runs at a time by default. When catchUpMissed is set, an automation that missed at least one occurrence while the process was down gets one catch-up run at startup, not one per missed occurrence; otherwise the gap is logged and skipped.
POST /schedules/validate-cron returns {"valid": true, "description": "..."} for an acceptable expression and {"valid": false, "error": "..."} otherwise. Both POST /schedules and PUT /schedules/{id} validate the expression before writing anything, and aigo schedule create calls the validation endpoint first so a typo is reported with the server's own reason instead of a bare 400.
Endpoints¶
Paths omit the /api/v1 prefix. {id} is the automation id everywhere under /schedules, and the execution id under /executions.
Automation CRUD¶
| Method | Path | Scope | Body or query | Returns |
|---|---|---|---|---|
GET | /schedules | agent_read | - | Schedule[] |
POST | /schedules | agent_write | CreateScheduleRequest | Schedule (201) |
POST | /schedules/validate-cron | agent_read | {"expression": "..."} | {valid, description?, error?} |
GET | /schedules/{id} | agent_read | - | Schedule |
PUT | /schedules/{id} | agent_write | UpdateScheduleRequest | Schedule |
DELETE | /schedules/{id} | agent_write | - | 204 |
PATCH | /schedules/{id}/toggle | agent_write | {"enabled": true} | Schedule |
POST /schedules requires name, cronExpression, modelPath, and promptTemplate; every other field has a default. A created automation is always enabled, whatever the request said.
PATCH /schedules/{id}/toggle takes the state it should end in rather than flipping the stored one, which is what makes aigo schedule enable and aigo schedule disable idempotent while aigo schedule toggle reads the current state first.
DELETE /schedules/{id} removes the automation and its whole execution history.
Running and execution history¶
| Method | Path | Scope | Body or query | Returns |
|---|---|---|---|---|
POST | /schedules/{id}/run | agent_write | ?wait= (default true) | ScheduleExecution (200) or {executionId} (202) |
GET | /schedules/{id}/executions | agent_read | ?limit= ?status= ?offset= | ScheduleExecution[] or {executions, total} |
GET | /executions | agent_read | ?limit= ?offset= ?scheduleId= ?status= | ScheduleExecution[] or {executions, total} |
GET | /executions/{id} | agent_read | - | ScheduleExecution |
DELETE | /executions/{id} | agent_write | ?scheduleId= (required) | {success, message} |
GET /executions/{id} resolves an execution id on its own by walking every automation's history, which is what makes the id returned by an asynchronous run usable without also carrying the automation id.
DELETE /executions/{id} needs scheduleId to scope the lookup. The CLI reads it off the execution record when --schedule is omitted, at the cost of one extra request.
Portability¶
| Method | Path | Scope | Body or query | Returns |
|---|---|---|---|---|
GET | /schedules/export | agent_read | - | ScheduleExportDocument |
GET | /schedules/{id}/export | agent_read | - | ScheduleExportDocument |
POST | /schedules/import | agent_write | document or array, ?onConflict= | {created, skipped, replaced} |
POST | /schedules/{id}/duplicate | agent_write | {"name": "..."} (optional) | Schedule |
Event stream¶
| Method | Path | Scope | Body or query | Returns |
|---|---|---|---|---|
GET | /schedules/events | agent_read | ?types= | text/event-stream |
GET | /schedules/ws | agent_read | ?types=, ?since= | WebSocket (101) |
Registered before /schedules/{id}, so events is read as a literal segment rather than an automation id. See Events.
Absent versus null on update¶
PUT /schedules/{id} reads an absent key as "leave this alone", which is what makes a partial update partial. Four fields go further and read an explicit null as "clear this": agentId, systemPrompt, enabledTools, and toolPermissionOverrides.
The distinction is not something a caller can infer from the field types, so state it explicitly when writing a client:
leaves the agent, the system prompt, the tool list, and the overrides exactly as they were, while
unsets both. Every other field is two-state: send it to change it, omit it to keep it.
inferenceParams and toolPermissionOverrides are replaced wholesale when present, not merged. aigo schedule update reads the stored automation back before it sends either one, so --temperature 0.2 alone does not reset maxTokens and --tool-permission run_shell=never_allow does not drop the other overrides. A client writing to the API directly has to do the same read.
enabledTools has no merge: a PUT naming a tool list replaces it, so name every tool the automation should keep.
Two shapes for an execution listing¶
Both list endpoints answer in one of two shapes, and which one you get depends on the query:
- With no filter parameter, the response is a bare array. This is the shape both endpoints have always returned, and it is what every already-deployed client reads.
- With a filter parameter, the response is
{"executions": [...], "total": n}, wheretotalcounts every record the filter matched beforelimitandoffsetwere applied, so a caller can page without asking twice.
The filter parameters that switch the shape are status or offset on GET /schedules/{id}/executions, and scheduleId or status on GET /executions. limit alone does not switch it.
The two shapes are also ordered differently, which is a deliberate consequence of leaving the older one byte for byte intact. On GET /schedules/{id}/executions, the bare array is the most recent limit records oldest first; the paged shape is most recent first. limit=0 diverges the same way, meaning "no records" in the bare shape and "no ceiling" in the paged one. GET /executions is most recent first in both shapes. The paged shape defaults limit to 50 on both endpoints; only the bare per-automation array has no default cap.
Passing ?offset=0 is the cheapest way to opt a per-automation listing into the paged shape and its ordering. The paged shape becomes the default in a later release; until then, do not assume either ordering without naming a filter.
Running an automation now¶
POST /schedules/{id}/run runs the automation immediately, regardless of enabled. It is recorded like any other run, so it does move the automation's clock: lastRunAt is set when the run finishes, and for an enabled automation nextRunAt is recomputed from that moment rather than left where it was. A manual run that is still going when a scheduled occurrence comes due therefore consumes that occurrence, and the scheduled run does not happen.
wait=true, the default, blocks until the run finishes and answers 200 with the finished ScheduleExecution. A run that involves loading a model can hold the connection open for a long time.
wait=false answers 202 with {"executionId": "..."} as soon as a record has been persisted in running state. Poll GET /executions/{id} for the outcome. This is what aigo schedule run --no-wait sends, and the id it prints pipes straight into aigo schedule execution show.
Cancelling a run¶
DELETE /executions/{id}?scheduleId=<id> marks the record cancelled and aborts the in-flight inference in the process that owns the run. A run started by the scheduler loop or by a wait=false call in that same process stops promptly.
A cancel issued against a different process does not reach the running task. It flips the stored record, and the owning process honors that at its next write: when the run finishes, the executor sees the record was cancelled and keeps cancelled rather than overwriting it with the outcome. So the record is correct either way, but the model keeps generating until it is done. Do not present a cross-process cancel as an immediate stop.
Cancelling an execution that is already terminal is a 400. A record left in running by a crashed process is not reconciled at startup: it stays non-terminal until something cancels it, so treat a running execution older than any plausible run as stale rather than live.
Export and import¶
What an export carries¶
GET /schedules/export and GET /schedules/{id}/export return the same document shape:
{
"version": 1,
"exportedAt": "2026-09-07T04:00:00Z",
"schedules": [ { "name": "nightly", "cronExpression": "0 2 * * *", "modelPath": "/models/qwen3-8b.gguf", "promptTemplate": "Summarize {{input}}" } ]
}
The example is abridged: every entry also carries inferenceParams, inputSource, outputAction, autoLoadModel, unloadAfter, catchUpMissed, and timezone, which are always serialized. Each entry holds exactly the fields POST /schedules accepts, which is what makes an import a plain create. Everything the running system owns is left out on purpose: the id, createdAt and updatedAt, lastRunAt and nextRunAt, the enabled state, and the entire execution history. Execution records carry rendered prompts and model output, so shipping them inside a document meant to be mailed around would turn "copy this automation" into "copy everything it has ever produced".
version is the document schema version. An import refuses a version it does not recognize rather than guessing at it.
An export file is credential material¶
An export carries secrets verbatim, and this is deliberate. Three typed fields are secrets in practice:
outputActionof typewebhookholds a URL that is frequently a bearer secret in itself. A Slack or Discord webhook URL is exactly that: anyone holding it can post as the integration.inputSourceof typecommandholds a shell command line, which may embed a token or a path to one.inputSourceof typeurlcan carry a token in its query string.
Nothing is redacted, because an automation stripped of them does not work after an import, and a document that silently produces a broken automation is worse than one the reader knows to protect. The free-text fields carry whatever was put in them, so a key pasted into promptTemplate or systemPrompt travels too, and modelPath along with any file and directory paths discloses the source machine's layout.
Handle an export file the way you would handle the credentials inside it: do not commit it, do not attach it to an issue, and delete it once the import is done. aigo schedule export -o <PATH> writes the file 0600 on Unix for this reason; a document you fetch with curl and redirect yourself gets whatever your umask gives it.
Import conflict modes¶
POST /schedules/import accepts either the document above or a bare array of create requests, so a hand-written array works as well as an exported file. ?onConflict= decides what happens when an incoming name is already taken. Conflict is matched by name, because an export carries no ids and the name is the only thing two machines can agree on.
| Mode | Behavior |
|---|---|
skip (default) | Leave the stored automation alone and report its name under skipped. |
rename | Create the incoming one as <name> (2), <name> (3), and so on. The stored list is re-read per entry, so two entries in one payload claiming the same name both land. |
replace | Update the stored automation in place, through the same path a PUT uses. Its id, enabled state, and run history survive. |
replace applies the incoming document field for field, so a field the incoming document omits is cleared, not left as it was. That is the intended reading of "replace": the result matches the document, not a merge of the document with what was there. enabled is the one exception, and it is excluded on purpose: replacing a configuration must not silently start or stop an automation.
The response accounts for every entry in the payload across three disjoint lists:
The whole payload is validated before anything is written. Every entry needs a non-empty name and a parseable cron expression, and at most 500 automations are accepted per request. One bad entry refuses the entire import rather than leaving half of it behind, so a validation 400 means nothing changed.
An imported automation arrives enabled¶
An imported automation is enabled the moment it is created, whatever state it was in on the machine it came from. The export document carries no enabled state to restore, and POST /schedules creates every automation enabled.
The consequence is worth planning for rather than discovering: importing a document that contains a webhook output action starts posting to that webhook on its cron schedule as soon as the next occurrence comes around. If that is not what you want, disable each new automation immediately, using the ids the import response returns:
curl -s -X POST "$BASE/schedules/import?onConflict=skip" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d @automations.json | jq -r '.created[]' \
| while read -r ID; do
curl -s -X PATCH "$BASE/schedules/$ID/toggle" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"enabled": false}' > /dev/null
done
replace does not have this problem: it keeps the stored automation's enabled state.
Duplicating¶
POST /schedules/{id}/duplicate copies an automation disabled, which is the opposite of what an import does and for a reason: a duplicate exists to be edited, and a second automation firing the original's webhook on the original's cadence between the copy and the edit is not what the caller asked for.
The body is optional. Without it the copy is named <name> (copy), then <name> (copy) (2) for a second copy. With {"name": "..."} the name is used as given, and a blank or already-taken name is a 400.
Events¶
GET /schedules/events and GET /schedules/ws provide SSE and WebSocket views of everything that happens to automations on this server. Both require agent_read, the same scope as GET /schedules, so a key that can list automations can watch them without holding admin.
| Event name | Payload | Emitted when |
|---|---|---|
schedule:execution-started | scheduleId, executionId, scheduleName | An execution starts. |
schedule:execution-completed | scheduleId, executionId, scheduleName, durationMs | An execution finishes successfully. |
schedule:execution-failed | scheduleId, executionId, scheduleName, error | An execution fails or is skipped. |
schedule:execution-cancelled | scheduleId, executionId, scheduleName | A run was cancelled through DELETE /executions/{id}. |
schedule:changed | scheduleId, change | An automation was created, updated, deleted, enabled, or disabled. |
change is one of created, updated, deleted, enabled, or disabled. A create, an edit, a toggle, an import, and a duplicate all emit it, one event per automation actually written: a 40-entry import emits 40, and an import entry that was skipped emits none. Every payload also carries the internal type tag (started, completed, failed, cancelled, changed), so one parser serves both this stream and the desktop event below.
?types= narrows the stream to a comma-separated subset, for example ?types=schedule:changed. A name outside the schedule: family is a 400 rather than a connection that silently delivers nothing. It honours Last-Event-ID and ?since= and reports a truncated replay with stream:gap and a lagging consumer with stream:lagged, exactly as GET /events does; replayed events pass the same domain filter the live stream applies. See Event Streams.
The stream carries only schedule:*. A data_read- or memory_read-scoped key watching its own domain gets the same treatment from GET /data/events and GET /memory/events; GET /events remains the cross-domain stream and still requires admin.
The desktop app additionally emits one Tauri event, schedule-event, carrying the same internally tagged union. It predates this stream and is unchanged, and it stays on the Tauri surface alone, so a client watching GET /events sees each schedule event once, under its schedule:* name.
This stream works on the desktop app's own embedded Management API too. Every desktop subsystem emits through one shared event sink, and that server attaches its own event bus as a second surface when it starts and releases it when it stops (issue #5040), so a restart moves delivery to the new server's bus and the desktop UI keeps receiving throughout. Before that fix this stream stayed open and silent there, and so did GET /events for these events.
Walkthroughs¶
The examples use an access key in X-API-Key. Drop the header if the server runs on loopback with Require API key off.
Create an automation with a file input and a webhook output¶
BASE=http://127.0.0.1:8001/api/v1
KEY=<your-access-key>
# Check the expression first; the create call validates it too, but this
# reports the reason rather than a bare 400.
curl -s -X POST "$BASE/schedules/validate-cron" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"expression":"0 2 * * *"}' | jq .
SCHEDULE=$(curl -s -X POST "$BASE/schedules" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"name": "nightly-digest",
"cronExpression": "0 2 * * *",
"timezone": "Asia/Seoul",
"modelPath": "/models/qwen3-8b.gguf",
"promptTemplate": "Summarize the following log for {{date}}:\n\n{{input}}",
"inputSource": {"type": "file", "value": "/var/log/app/today.log"},
"outputAction": {"type": "webhook", "value": "https://hooks.example.com/T000/B000/xxx"},
"inferenceParams": {"temperature": 0.2, "maxTokens": 1024, "topP": 0.9, "stream": false}
}' | jq -r .id)
curl -s -H "X-API-Key: $KEY" "$BASE/schedules/$SCHEDULE" | jq '{name, cronExpression, timezone, enabled, nextRunAt}'
Run it now and read the result¶
# Blocking: the finished execution record comes back.
curl -s -X POST "$BASE/schedules/$SCHEDULE/run" -H "X-API-Key: $KEY" \
| jq '{status, tokensUsed, result}'
# Non-blocking: an id to poll.
EXEC=$(curl -s -X POST "$BASE/schedules/$SCHEDULE/run?wait=false" -H "X-API-Key: $KEY" | jq -r .executionId)
until [ "$(curl -s -H "X-API-Key: $KEY" "$BASE/executions/$EXEC" | jq -r .status)" != "running" ]; do
sleep 5
done
curl -s -H "X-API-Key: $KEY" "$BASE/executions/$EXEC" | jq -r '.result // .error'
# Cancel it instead. Terminal records refuse with a 400.
curl -s -X DELETE "$BASE/executions/$EXEC?scheduleId=$SCHEDULE" -H "X-API-Key: $KEY" | jq .
Read execution history¶
# One automation, newest first, with the unpaged match count.
curl -s -H "X-API-Key: $KEY" \
"$BASE/schedules/$SCHEDULE/executions?offset=0&limit=20" \
| jq '{total, rows: [.executions[] | {id, status, startedAt}]}'
# Recent failures across every automation.
curl -s -H "X-API-Key: $KEY" "$BASE/executions?status=failed&limit=20" \
| jq -r '.executions[] | "\(.startedAt)\t\(.scheduleId)\t\(.error)"'
Move automations to another machine¶
# On the source machine. The file that lands here holds webhook URLs and
# command lines in the clear; see the credential warning above.
curl -s -H "X-API-Key: $KEY" "$BASE/schedules/export" > automations.json
chmod 600 automations.json
# On the target machine. `rename` keeps both copies of any name clash.
curl -s -X POST "$TARGET_BASE/schedules/import?onConflict=rename" \
-H "X-API-Key: $TARGET_KEY" -H 'Content-Type: application/json' \
-d @automations.json | jq .
# Everything imported is enabled. Turn one off before its next occurrence.
curl -s -X PATCH "$TARGET_BASE/schedules/<new-id>/toggle" \
-H "X-API-Key: $TARGET_KEY" -H 'Content-Type: application/json' \
-d '{"enabled": false}' | jq '{name, enabled}'
rm -f automations.json
See also¶
- Automations for the desktop page these endpoints sit behind.
- CLI reference for the
aigo schedulecommand group and its flag grammar. - Squads for the multi-agent surface, which shares the
agent_readandagent_writescopes. - External access for binding the Management API to a non-loopback address safely.