Automations¶
An automation runs a workflow again and again on a trigger: "check the price of ACME every 5 minutes", "report this machine's memory use every 2 minutes", "run this report when I ask". AbstractRuntime runs automations itself, as ordinary durable runs, so a runtime and a run store are enough to run one. A host such as AbstractGateway exposes them over HTTP and drives them in its run loop.
This page covers the model, the definition, triggers, the controller, commands, context, discussions, tool approval, notifications and the storage guarantees automations rely on. For the surrounding runtime concepts see architecture.md; for the import surface see api.md.
Code: abstractruntime.automations (controller, commands, service API), abstractruntime.triggers (trigger
sources), abstractruntime.automation_queries (listing), abstractruntime.session_turns and
abstractruntime.session_history (what counts as a turn and how history is replayed).
Mental model¶
- An automation is a run. It is a durable root run, the controller, that executes the packaged flow
abstractframework.automation-controller@1.0.0. The controller's run id is the automation id. - Each firing is an occurrence. An occurrence is a child run of the controller with a deterministic id. At most one occurrence runs at a time. Its child runs (tools, subworkflows) are its descendants.
- Occurrences are turns. In the session views and history the runtime builds, an occurrence counts as one conversation turn: its prompt is the user turn and its answer is the assistant turn.
- Context is independent or growing. By default each occurrence starts fresh in its own session. In growing mode every occurrence joins the automation's session and sees the previous ones as history.
- Automations are quiet. An occurrence asks for attention only when its output says
notify, when it still fails after its last retry, or while it waits on a person. - Creating an automation is consent for its tools. With the default
tool_approval: "auto"an occurrence's tools run without asking, except messages to anyone but the registered user (those wait for approval); set"ask"to approve every batch. - Everything is recorded. Every admission, dispatch, retry, completion and command is an
automation.*record in the controller's ledger. A crash at any point never loses an occurrence and never starts one twice.
flowchart LR
Trigger["Trigger source<br/>schedule@1 / manual@1"] -->|"due tick or run now"| Controller["Controller run<br/>(automation id)<br/>vars._meta.automation<br/>vars._runtime.automation"]
Commands["apply_automation_command<br/>pause / resume / run_now /<br/>revise / stop_current / archive / unarchive"] -->|"decision + wake"| Controller
Controller -->|"START_SUBWORKFLOW<br/>deterministic run_id"| Occurrence["Occurrence run<br/>role: occurrence<br/>(a session turn)"]
Occurrence --> Descendants["Descendant runs<br/>role: descendant"]
Occurrence -->|"output: answer, notify"| Controller
Controller -->|"automation.* records"| Ledger["Controller ledger<br/>occurrences, attention, commands"]
Occurrence -.->|"fork: own workspace + read-only mount"| Discussion["Discussion run<br/>own session"]
Quick start¶
import os
from abstractruntime import Runtime
from abstractruntime.automations import (
controller_workflow_spec,
create_automation,
drive_automation,
register_controller_bundle,
)
from abstractruntime.scheduler.registry import WorkflowRegistry
from abstractruntime.storage.sqlite import SqliteDatabase, SqliteLedgerStore, SqliteRunStore
db = SqliteDatabase("automations.sqlite")
registry = WorkflowRegistry()
registry.register(memory_check) # the target: any WorkflowSpec
register_controller_bundle(registry) # the packaged controller flow
runtime = Runtime(run_store=SqliteRunStore(db), ledger_store=SqliteLedgerStore(db), workflow_registry=registry)
automation_id, revision = create_automation(runtime, {
"request_id": "memory-watch-1", # the same request finds the same automation
"title": "Memory watch",
"target": {"workflow_id": memory_check.workflow_id, "bundle_ref": "local@0.0.0",
"flow_id": "memory_check", "input_data": {"prompt": "Report memory use; notify me above 90%."}},
"trigger": {"source_id": "schedule", "source_version": 1, "config": {"every": "2m"}},
"workspace_root": os.path.abspath("automations/memory-watch"),
})
drive_automation(runtime, automation_id) # runs occurrence 1 now, then parks until the next tick
Without start_at, the schedule starts at creation time, so the first occurrence runs on the controller's first
step. drive_automation ticks the controller and its current occurrence, resumes the controller when the occurrence
ends, and returns when the controller parks (on its wake wait, or on an occurrence that waits on a person).
To fire later ticks without a host loop, tick the controller once its deadline has passed (a due wake wait is
released by Runtime.tick) and drive it again:
runtime.tick(workflow=controller_workflow_spec(), run_id=automation_id)
drive_automation(runtime, automation_id)
A host with its own run loop does not need drive_automation: the controller is an ordinary run that waits on an
event with a deadline (listed by list_due_wait_until), and waits on its occurrence like any asynchronous
subworkflow parent.
The definition¶
The definition is stored in vars._meta.automation of the controller run. Every change creates a new revision, and
unknown fields are rejected at every level.
| Field | Meaning |
|---|---|
schema_version |
2 (a stored 1 reads with the defaults of the v2 fields and becomes 2 at its next revision) |
revision |
starts at 1; each applied automation.revise adds 1 |
title |
non-empty, at most 120 characters |
controller |
{bundle_ref: "abstractframework.automation-controller@1.0.0", flow_id: "controller"} |
target |
{workflow_id, bundle_ref, flow_id, input_data}: a concrete workflow; a host resolves @default before creating (flow_id: "@default" is refused) |
trigger |
{binding_id, source_id, source_version, config}; the runtime creates binding_id |
context |
{mode: "independent" \| "growing", growing: {}} |
policy |
{serial: true, misfire: "coalesce", failure: "continue", retry, tool_approval, email_allowed_recipients, untrusted_input_tools} |
notify |
{channels: ["console"] \| ["console", "email"]}: where attention items are delivered (default ["console"]) |
session_id |
automation:<automation_id> |
workspace_root |
absolute path given to every occurrence |
created_at, archived_at |
UTC timestamps; archived_at is null until the automation is archived |
policy.retry is {max_attempts, backoff: {initial, factor, max}}, by default
{max_attempts: 3, backoff: {initial: "30s", factor: 2, max: "10m"}}. max_attempts is 1 to 10, factor is 1 to 10,
and initial and max are durations (see schedule@1). serial, misfire and failure accept only
the values shown; anything else is refused with unsupported_feature. policy.tool_approval is "auto" (default) or
"ask", see Tool approval. policy.email_allowed_recipients lists who occurrences may email
without an approval wait: "self" (the user's registered address) and exact addresses, at most 50, default
["self"]; display names, domains and patterns are refused. A revision that changes other policy fields keeps it.
policy.untrusted_input_tools (default [], at most 20) names, one by one, tools that an occurrence of an untrusted
trigger (email.received@1) may run without asking although "allow all tools" does not cover them there (a tool
that reaches the network, runs code or commands, writes outside the workspace or delegates), for example
["fetch_url"]; "all" and patterns are refused, and message-sending tools are never granted this way. See
Tool approval.
Creating an automation¶
create_automation(runtime, request, *, now=None, actor_id=None) -> (automation_id, revision).
The request is {request_id, title, target, trigger: {source_id, source_version, config}, context?, policy?,
notify?, workspace_root, tenant?, user?}.
- The automation id is
uuid5(AUTOMATION_NAMESPACE, "<tenant>:<user>:<request_id>");tenantanduserdefault tolocal. Sending the same request again returns the same automation, unchanged. Reusing arequest_idfor a different request raisesAutomationErrorwithreason_code = "identity_conflict". actor_id(the owner) is stamped on the controller run in the same create-if-absent step.- An
automation.createdrecord is written once. - Validation failures raise
AutomationError. Itsreason_codeisinvalid_definition,unsupported_featureorunknown_trigger_source, and itsfieldnames the offending field (trigger.config.every, for example). context.growing.summaryis refused withunsupported_feature: automatic summaries of growing history are not part of v1.
Triggers¶
A trigger source is an adapter with six methods (validate, initial_state, prepare, admit, rearm,
normalize; see abstractruntime.triggers.protocol). It reads persisted state and the time it is given, and never
does I/O, so the controller, the command applier and tests always get the same answer for the same inputs.
trigger_sources() lists every discovered source as {descriptor, available, unavailable_reason?, name?}. Each
descriptor carries id, version, label, config_schema, event_schema and capabilities.kind (time,
manual or event). get_trigger_adapter(id, version) returns an available source or raises
UnknownTriggerSource.
schedule@1¶
Config: {start_at?, every?, until?, count?, anchor?}.
- Timestamps are RFC 3339 with an explicit offset (
2026-10-01T08:00:00Z); a timestamp without an offset is refused. everyis a whole number followed bys,m,hord(^[1-9][0-9]*[smhd]$), at most366d. Units have fixed lengths in UTC, so there are no months and no daylight-saving shifts:24his exactly 24 hours. Write weeks as7d.- Ticks sit on a fixed grid,
T_k = anchor + k·every(k = 0, 1, ...). They do not drift: an occurrence that starts 7 seconds late does not move the next tick. start_atdefaults to the creation time, and the first tick isstart_atitself.anchormust equalstart_atin v1.untilis exclusive and must be afterstart_at.count(1 to 1,000,000) counts scheduled admissions; manual runs and retries do not count.countgreater than 1 requiresevery.- Without
every, the automation fires once, atstart_at, and is then exhausted. - Missed ticks are coalesced. When several ticks are due at once (downtime, or a long occurrence), one occurrence
runs, for the latest due tick. Its event payload is
{tick, scheduled_at, coalesced: {first_tick, last_tick, missed_count}}, and anautomation.coalescedrecord is written. - The event id of a scheduled tick is
schedule@1:<binding_id>:<tick>. A revision that changes the trigger gets a newbinding_id, so the new schedule never reuses an old event id.
manual@1¶
Config: {}. The automation runs only when asked with automation.run_now. A manual run of any automation, whatever
its trigger, carries a manual envelope with event id manual:<command_id>.
email.received@1¶
Runs the automation when mail arrives in the user's mailbox, in batches no more often than config.every (default
1h when uses_model is true, the default, and 60s otherwise), each message at most once. It reads the runtime's
durable event inbox, which the host's mail watcher fills; creating it on a runtime without an inbox is refused with
unsupported_feature. Its occurrences receive the messages as input_data.trigger (marked untrusted) and, for a
string prompt, inside a fixed untrusted frame (both stored as artifacts; the controller's records carry refs);
their unattended grant covers only tools with no network egress, execution, messaging, writes outside the workspace
or delegation. See email.md for the config, the filters and the guarantees.
Adding a source¶
Declare the adapter in the entry-point group abstractruntime.trigger_sources:
[project.entry-points."abstractruntime.trigger_sources"]
my_source = "my_package.triggers:MySourceAdapter"
- The entry-point name must equal
descriptor.id,descriptor.versionis an integer of at least 1, andcapabilities.kindistime,manualorevent. - A third-party source that fails to load, has a bad descriptor, or duplicates another
id@versionis listed withavailable: falseand a reason. It cannot be selected, and the other sources keep working. - The built-in sources are required: if one is missing or broken, discovery raises
TriggerRegistryError. reset_trigger_registry()forgets cached discovery (after installing a source package, or in tests).- The controller waits on deadlines (
preparereturninguntil), without a deadline (idle: commands, or a watcher's wake for event sources), or ends (exhausted). A source whosepreparereturns aneventwait fails the controller step. - A source whose
capabilities.kindiseventreceivesevents=(the inbox records after its cursor, read by the controller) inprepareandadmit, may keep its own state insource_state, and may returninputswith an admission (data for the occurrence's inputs that stays out of the recorded envelope).
The controller¶
The controller is the packaged VisualFlow bundle abstractframework.automation-controller@1.0.0 (package data under
abstractruntime/automations/bundles/). Every controller run keeps the workflow id
abstractframework.automation-controller@1.0.0:controller, so a restarted host resolves exactly this version.
register_controller_bundle(registry) registers the compiled flow, controller_workflow_spec() returns it, and
controller_bundle_path() returns the bundle directory for hosts that load bundles from disk.
flowchart TD
start([start]) --> read_definition
read_definition -- "continue" --> wait
read_definition -- "end: archived or exhausted,<br/>nothing running" --> done([end])
wait -- "go: due tick, run now,<br/>retry due, or occurrence pending" --> admit
wait -- "park: WAIT_EVENT automation:id:wake<br/>until next tick / retry, or no deadline" --> wait
wait -- "end" --> done
admit -- "prepare: new occurrence admitted" --> prepare_context
admit -- "dispatch: occurrence already pending" --> dispatch
admit -- "rearm: nothing due (stale wake),<br/>backoff not due, or stopped" --> read_definition
prepare_context --> dispatch
dispatch -- "START_SUBWORKFLOW async + wait,<br/>deterministic run_id" --> record_outcome
record_outcome -- "dispatch: child not finished" --> dispatch
record_outcome -- "next: completed, failed,<br/>cancelled or retry scheduled" --> next_step[next]
next_step --> read_definition
- read_definition activates the latest revision. When the automation is archived or its trigger is exhausted, and no occurrence is pending, the controller ends.
- wait decides from persisted state. With nothing to do it parks on one
WAIT_EVENTwith keyautomation:<automation_id>:wake, whose deadline is the next tick or the retry time; there is no deadline while paused or for a manual trigger. Commands wake this wait. A wake only makes the controller re-read its persisted state, so a stale or repeated wake never starts an extra occurrence. - admit admits an occurrence only for a scheduled tick that is due while the automation is neither paused nor
archived, or for a pending manual run. It freezes the occurrence's inputs (
prepared: workflow, session, workspace, input data), the trigger envelope, the retry policy and the session kind. Every attempt uses these, so a later revision never changes an occurrence that has already been admitted. - prepare_context checks that the frozen inputs resolve (values offloaded to the artifact store must load).
- dispatch records the attempt and starts the child with
START_SUBWORKFLOW {async: true, wait: true, run_id}(plusresolve_varsfor an email occurrence, whose messages and framed prompt stay artifact refs in the controller's records). The child runs outside the controller's tick, driven by the host. Its id is deterministic and it is created only if absent, so a dispatch replayed after a crash re-attaches to the same child. - record_outcome reloads the child and reads its output; it never relies on the resume payload. Success completes
the occurrence. A failure schedules a retry while attempts remain, and otherwise completes the occurrence as
failed. A cancelled child completes ascancelled.
A controller that cannot find a seam it requires (the run and ledger stores on the tick, a child created under the
deterministic id) fails its step with ControllerSeamError instead of continuing.
Occurrences¶
Each occurrence run carries vars._meta.occurrence = {automation_id, occurrence_index, attempt, event_id, revision,
role: "occurrence", session_kind, fired_at, trigger_envelope} and workspace_root. Its children carry the same
object with role: "descendant"; the runtime sets it on every hop and a child cannot clear or change it.
When the target's input_data.prompt is a string, it is prefixed on its own line with
[Trigger schedule@1 · occurrence 3 · fired 2026-10-01T08:06:00+00:00], and the result is the occurrence's user
turn.
Run ids are deterministic:
| Occurrence | Run id |
|---|---|
| scheduled, attempt 1 | uuid5(automation_id, "<revision>:<index>") |
| manual (run now), attempt 1 | uuid5(automation_id, "manual:<command_id>") |
| attempt n ≥ 2 | the attempt-1 name with ":a<n>" appended |
occurrence_run_id(automation_id, revision=, index=, attempt=, command_id=) computes them.
Commands¶
apply_automation_command(runtime, *, automation_id, command_id, type, payload=None, actor=None,
expected_revision=None, now=None) is the only way to change an automation. It returns {status: "applied" |
"rejected", error?, duplicate} and returns rejections instead of raising them.
| Type | Effect | Rejected with |
|---|---|---|
automation.pause |
Stops scheduled admissions. An attempt already running finishes, but no retry follows: an occurrence waiting in retry backoff is cancelled (quietly), and a scheduled attempt that fails while paused completes failed without a retry. run_now still works (a manual run keeps its retries). This is separate from the runtime's own pause. |
invalid_state when archived or finished |
automation.resume |
Re-arms the schedule at the first tick after now. It never fires on resume and never catches up the paused time. Resuming an automation that is not paused is an applied no-op. | invalid_state when archived or finished |
automation.run_now |
Runs one occurrence at the controller's next step, even while paused (the automation stays paused). There is no queue. | automation_busy while an occurrence or a manual run is pending; invalid_state when archived, finished or exhausted |
automation.revise |
payload.changes = {title?, target?, trigger?, context?, policy?} (at least one). title, target, trigger and context are replaced whole; policy fields are merged, so a field you do not send keeps its value. The next revision applies from the controller's next step. A changed trigger gets a new binding and is re-armed after now, so no past tick fires; an unchanged trigger keeps its binding. |
invalid_state when archived or finished; the definition's own reason codes for invalid changes |
automation.stop_current |
Cancels the running occurrence tree, or its pending retry. The occurrence completes as cancelled, quietly. |
invalid_state when nothing is running |
automation.archive |
Stops further admissions; the current occurrence finishes and the controller then ends. History is kept and the automation stays listed. Archiving twice is an applied no-op. | — |
automation.unarchive |
Lifts the archive mark and brings the automation back paused (send automation.resume to re-arm its trigger). A controller that ended because it was archived restarts at read_definition and parks on its wake wait; occurrences, ledger and history are untouched. An exhausted or failed automation only loses the archive mark and keeps its status. Unarchiving an automation that is not archived is an applied no-op. |
— |
Every command can also be rejected with:
automation_not_found: no automation has that id;invalid_request: missingcommand_idor unknowntype;revision_conflict(fieldexpected_revision):expected_revisionwas given and differs from the current revision when the command is applied;identity_conflict(fieldcommand_id): thecommand_idwas already used for a different command.
Commands are idempotent per command_id. A command_id is tied to its whole command (type, payload and
expected_revision). The automation.command_result record, keyed
automation:command_result:<automation_id>:<command_id>, is the decision. Sending the same command again returns the
recorded result with duplicate: true and repeats only the follow-ups that are safe to repeat: the observation
record (automation.paused, automation.resumed, automation.revised, automation.archived, automation.unarchived), waking the
controller, and cancelling the stopped child. A host that fails to carry out a command itself records the failure
with record_automation_command_result(...), passing the same payload and expected_revision so a later replay is
recognized.
Commands run under the controller's run_mutation_lock, so they never interleave with a controller step. The
controller is woken with max_steps=0; the host (or drive_automation) then ticks it like any resumed run.
Context: independent or growing¶
| Independent (default) | Growing | |
|---|---|---|
| Session | a new session per occurrence, named after its attempt-1 run id | the automation's session, automation:<automation_id> |
| History given to the occurrence | none | the automation's previous turns as input_data.context.messages: the most recent whole turns within the configured token budget (50,000 tokens by default) (the session history window) |
use_context given to the target |
false |
true |
_meta.occurrence.session_kind |
occurrence |
automation |
The context mode alone decides whether the target reads history: at admission the runtime sets the target's
use_context input (and include_context when the target has it) to true for growing and false for
independent, whatever the definition's target.input_data says. A discussion always gets use_context: true.
The run records the decision as _runtime.automation_context = {mode, use_context, target_use_context}, where
target_use_context is the value the definition carried. Automations created before this rule, with
use_context: false in their target, now replay their history without being revised.
Growing history is read at admission and frozen with the occurrence's inputs, so every retry sees the same history.
It is read strictly: when it cannot be read (a store without a run index, for example), the admission fails instead
of running the occurrence without its context. History keeps whole turns, newest first, and says so in the oldest
kept message when older turns were dropped. The occurrence run records what was replayed in
vars._runtime.session_history (replayed_messages, replayed_tokens, dropped_messages, dropped_tokens,
max_tokens, ...), and the automation.admitted record carries the same values in its frozen inputs.
Only completed turns with both a prompt and an answer are replayed. A retried occurrence counts once, as its last attempt.
Discussions¶
start_discussion(runtime, *, automation_id, occurrence_index, request_id, prompt, workspace_root,
actor_id=None) starts a separate conversation about the automation as it stood at occurrence N, and returns
{session_id, run_id, session_kind: "discussion"}. Forking at a different point in time means choosing another N.
- It creates a new root run,
uuid5(automation_id, "discuss:<request_id>"), in its own sessiondiscussion-session:<run id>. Request ids are therefore scoped to the automation. The same request returns the same discussion; a different request under the samerequest_idon that automation raisesidentity_conflict. - The run uses the occurrence's workflow and frozen inputs, with
promptas the new user turn. The workflow must be registered on the runtime. - It is seeded once with the automation's whole conversation through occurrence N, whatever the context mode:
one user/assistant pair per finished occurrence 1..N (its last attempt), the occurrence's trigger/task turn and its
answer, oldest first (
automation_timeline_messages(...)). A failed or stopped occurrence stays in the timeline, its answer saying so. The pairs go through the session history window (the most recent 50,000 tokens of whole turns, no message ever cut); the oldest are dropped first. The first user message starts with a summary line:[Automation "<title>": <n> occurrence(s) through occurrence N, showing the last K. The automation's files are mounted READ-ONLY at <path>; your own workspace <path> is writable.]. The seed is stored in_meta.discussion.seed_messagesof the root run. - It works in its own writable workspace,
workspace_root, which the host allocates (it must differ from the automation's). The automation's workspace is mounted read-only alongside it: it is reachable (workspace_access_mode: "workspace_or_allowed", listed inworkspace_allowed_paths) and protected by_runtime.workspace_read_only_paths, so reads work and writes, edits and moves into it are refused. Commands and tools run normally in the discussion's own workspace._meta.discussion.mounted_workspacenames the mount (see Read-only mounts). When the occurrence's inputs carry the host's built-in protection (workspace_builtin_deny_prefixes, e.g. the gateway's data dir), the discussion keeps those deny prefixes unchanged and setsworkspace_builtin_allowto exactly its own workspace and the mount, so both roots are usable and nothing else in the protected folders is. - The automation's tool grant is removed: tools in a discussion ask for approval as in any chat.
- Nothing is ever written back into the automation's session, state, ledger or workspace.
Errors: occurrence_not_found (the automation has no occurrence N), invalid_request (empty prompt or
request_id), automation_not_found, identity_conflict, and SessionHistoryError when the seed cannot be read.
Later turns stay anchored. Every later root run started in a discussion session, by any caller, gets the
discussion's provenance (_meta.discussion without the seed) and the root's own workspace setup, whatever the caller
passed: workspace_root, workspace_access_mode, workspace_allowed_paths, the root's exact
workspace_builtin_allow when it has one (so the mount stays readable under the host's built-in protection and a
caller cannot widen the list; deny prefixes are left as they are) and _runtime.workspace_read_only_paths (a caller
may add mounts, never remove one). The whole-workspace workspace_read_only flag is applied only when the
root carries it. The discussion's root is validated first: every discussion run of the session must name
the same root, and that root must be a parent-less run of this session that carries the seed. If this check fails,
Runtime.start raises SessionAttributionError and the run is not created. Children of discussion runs carry the
discussion provenance too.
In a discussion session, session_chat_messages replays the seed first, as the oldest history, and drops it first
under the history window.
Read-only mounts¶
_runtime.workspace_read_only_paths lists absolute folders that a run may read but not change, while its own
workspace_root stays writable. Discussions use it for the automation's workspace. Entries are resolved like
pwd -P (symlinks followed); the same key at the top level of the run vars is honoured too and can only add mounts.
- File tools classified
write(write_file,edit_file, ...) are refused when their target path lies inside a mount; the message comes fromread_only_refusal(name, path=...). - Reading inside a mount works (
read_file,list_files,search_files, ...). - Commands and code (
execute_command,shell_exec,execute_python, ...) are allowed: the shell cannot be sandboxed, so a mount protects the file tools and VisualFlow writers, not what a command does. - VisualFlow nodes that write files (
write_file,write_pdf,write_docx,write_chart,export_artifact) are refused for a path inside a mount. - Child runs and VisualFlow nodes inherit the mounts; they can add more but never clear or shrink them.
- A run with mounts and no
workspace_rootis refused.
Helpers in abstractruntime.utils.workspace_paths: read_only_paths(vars) (the resolved mounts) and
path_is_read_only(vars, path); READ_ONLY_PATHS_KEY is the key name inside _runtime.
Read-only workspaces¶
A run is read-only when its vars carry workspace_read_only: true or the trusted runtime key
_runtime.workspace_read_only: true. (Discussions use read-only MOUNTS instead: see above.) Under a read-only
workspace:
- tools classified
writeorexecinTOOL_EFFECT_CLASSESare refused (write_file,edit_file,execute_command,shell_exec,local_helper_start,execute_python,self_improve, ...), and so is every tool the table does not classify; - tools classified
read,comms,delegateandmemory-writestill run (delegatechildren inherit the read-only setting); - VisualFlow nodes that write files (
write_file,write_pdf,write_docx,write_chart,export_artifact) are refused; - the workspace folder is never created, and a read-only run without a
workspace_rootis refused; - child runs and VisualFlow nodes inherit the setting and cannot turn it off.
abstractruntime.integrations.abstractcore.tool_effects holds the table: TOOL_EFFECT_CLASSES maps each tool the
runtime can expose to read, write, exec, delegate, comms or memory-write, and read_only_refusal(name)
returns the refusal message for a tool, or None when it is allowed.
Tool approval¶
policy.tool_approval decides whether the target's tools may run without asking.
"auto"(default). An automation runs unattended and cannot ask a person before every tool call, so creating the automation is the consent; client forms state that its tools run without asking and list them. At admission, the runtime freezes the run's tool grant into the occurrence's inputs:_runtime.tool_policy = {auto_approve_tools: [...], source: "automation-policy"}. The tools named are the target's explicit_runtime.allowed_toolswhen it has that list, and otherwise every tool inTOOL_EFFECT_CLASSES. Child runs inherit the grant.- A name outside the run's tool ceiling (
allowed_tools) grants nothing: approval never widens the ceiling. - A tool outside
TOOL_EFFECT_CLASSES(a third-party MCP tool, for example) is not in the grant and still asks. - A
tool_policythat the target's owninput_data._runtimealready carries is left as it is, except for untrusted triggers (below), whose occurrence policy always replaces it (replaced_target_policy: true). - Tools that message model-chosen recipients are never granted: every tool
whose inventory row carries
comms_send(send_email,reply_email,send_whatsapp_message,send_telegram_message,send_telegram_artifact), even whenallowed_toolsnames it. They are listed in the grant'swithheld_toolsand go through the normal approval point: asend_emailwhose every recipient is the registered user's address (_runtime.operator_email, set by the host; the gateway freezes it into the target's inputs) runs; any other recipient parks the occurrence on atool_approvalwait, as under"ask", until a person approves or refuses it. An occurrence that reads untrusted text (an inbound email, a fetched page) therefore cannot mail data to an address that text names. When the inventory cannot be read, everycommstool is withheld. - Pre-authorised recipients. Every occurrence carries the definition's
policy.email_allowed_recipientsas_runtime.email_allowed_recipients, replacing any value in the target's inputs. Asend_emailwhose every recipient is self or on that list runs unattended; see email.md. - Untrusted triggers: allow by kind. When the trigger delivers text written by other people
(
email.received@1), the agent acts only within the automation's mission and never follows links from the mail it reads."auto"then grants only tools whose facts prove all of the following (untrusted_input_allow_all): no network egress beyond services the user or administrator configured (the model provider, the user's own mailbox, the agora hub), no code or command execution, no message sending, no writes outside the run's workspace, and no delegation. The facts are the runtime's tool table (tool_effects.TOOL_EFFECT_CLASSES,TOOL_NETWORK_REACH,TOOL_WRITE_SCOPE) and AbstractCore's row facts (model_controlled_destination,comms_send,remote_write_capable,destructive_capable); a tool missing from the table is withheld. In practice the grant keeps file reads, workspace-confined file writes (write_fileandedit_fileonly underworkspace_access_mode: "workspace_only", the default), mailbox and hub reads,get_email_attachment(into the workspace),recall_memoryandupdate_plan(the run's own plan). It withholds, among others,fetch_url,browser_probe,skim_url,skim_websearch,web_search,execute_command,shell_exec,execute_python,delegate_agent,channel_fs_write,agora_post_message,agora_send_dm, the memory-writing tools (remember,remember_noteincludingscope: "global",compact_memory: they write the user's lasting memory outside the run's workspace,TOOL_WRITE_SCOPEmemory, so a stranger's email cannot plant a note that later runs obey) and every MCP tool. A withheld tool runs unattended only when the user named it individually inpolicy.untrusted_input_tools(within the target's tool ceiling); message-sending tools (send_email,reply_email,agora_post_message,agora_send_dm, ...) are never granted, named or not. Schedule and manual automations keep the full grant. - Untrusted triggers always carry a policy. The occurrence policy is marked
untrusted_input: true(withapproval,untrusted_input_toolsandreplaced_target_policy), the run carries_runtime.untrusted_input: true, and child runs inherit both with the parent's value winning over their own. For such a run the approval executor never consults its static policy (the gateway's defaults would auto-runskim_url,web_search, the agora posts and the Telegram sends) or a tier ceiling; it runs a listed tool unasked only when the user named it or (under"auto") its facts passuntrusted_input_allow_all, never runs a sending tool unasked, and applies the per-call refiners (send_emailto self or a pre-authorised recipient) only under"auto". "ask". No grant. A tool call that needs approval waits on atool_approvalwait, as in a chat. For an untrusted trigger (email.received@1) the occurrence still gets a per-run policy that auto-approves nothing but the tools the user named inpolicy.untrusted_input_tools(sending tools never) and lists every other tool inrequire_approval_tools: every other call waits for a person,send_emailto the user's own address included.
The grant is frozen with the rest of the occurrence's inputs: a revision of tool_approval applies from the next
occurrence. Questions a flow asks a person (ask_user) still wait in both modes. Discussions never inherit the grant.
See tool-approval.md for how auto_approve_tools combines with risk tiers.
Waits on a person¶
pending_waits(run_store, automation_id, *, limit=20) lists the runs of the automation's occurrence trees that are
waiting on a person. Each item is typed from the wait record's structure, never from its text:
{run_id, wait_key, kind: "ask_user" | "tool_approval" | "event", reason, index, prompt?, choices?, details?}
kind |
When | details |
Answer with Runtime.resume(run_id=..., wait_key=..., payload=...) |
|---|---|---|---|
ask_user |
a USER wait that is not a pause (a question from the flow) |
none | {"response": "..."} |
tool_approval |
a tool batch waiting for approval (details.mode == "approval_required") |
the calls approving will run, [{name, arguments, call_id?}] |
{"approved": true} or {"approved": false} |
event |
an EVENT wait that carries a prompt or choices |
{scope, name} when the wait uses the runtime's event key |
{"payload": ...} |
index is the occurrence number. Paused runs and the controller's own wake wait are never listed.
ANSWER_PAYLOADS, wait_kind(run), typed_wait(run) and is_interactive_wait(run) expose the same rules to hosts.
Waits on a person are live facts read from run state; they are not attention items.
Notifications, attention and retries¶
What asks for attention¶
Automations are quiet by default. An occurrence creates an attention item only when:
- its output carries
notify: true: the item's title is the automation title and its body is the answer, cut to 280 characters; - its output carries
notify: {title, body}: title at most 120 characters (the automation title when empty), body at most 2,000 characters. A missing,falseor emptynotifystays quiet unless result email is enabled; - it failed after its last retry: the title is "
failed" and the body is the error. A failure that a retry fixed is quiet, and so is a cancelled occurrence.
The output is read by structure. An agent-style target ends with response / success / meta; a plain flow ends
with its end-node values or {success, result}. An output with success: false counts as a failure. A workflow that
must flag a result adds notify next to its answer.
Each notify or final failure creates exactly one attention item per occurrence. The item is carried by the The item's channels repeats the definition's notify.channels; a host that delivers notifications by email
emails every completed result when email is listed. notify.recipients selects delivery addresses (default ["self"]); the full answer is retained for email while console previews stay bounded.
occurrence's automation.completed record with a per-automation sequence number.
list_attention(ledger_store, automation_id, *, after_seq=0, cursor=None, limit=50) returns
{items, next_cursor}, oldest first. Each item is {kind: "notify" | "failure", automation_id, run_id, index, at,
title, body?, seq, cursor}, and cursors look like att1:<seq>. A client should acknowledge only the cursor of the
last item it displayed, so items it never showed stay unseen.
Retries¶
policy.retry allows 3 attempts in total by default. The delay before attempt n + 1 is
min(initial · factor^(n−1), max): by default 30 s, then 60 s, capped at 10 minutes. Each attempt is its own child
run (...:a2, ...:a3) with the same frozen inputs and session. An automation.retry_scheduled record marks each
backoff, and automation.completed carries attempts. While an occurrence waits for its retry, scheduled ticks that
fall due are coalesced into one admission after it completes.
Retries do not make external effects exactly-once: a target that sends an email may send it again when it is retried.
Reading automations¶
| Function | Returns |
|---|---|
get_automation(run_store, automation_id) |
{automation_id, definition, active_revision, state, status, next_fire_at, current_occurrence} |
list_occurrences(runtime, automation_id, *, cursor=None, limit=50) |
{items, next_cursor}, newest first, built from the ledger. Items: {index, run_id, run_ids, attempts, revision, event_id, fired_at, trigger: {source_id, source_version}, user_turn, status, finished_at, notify, attention}; status is admitted, running, backoff, completed, failed or cancelled; cursors look like occ1:<index> |
list_attention(...), pending_waits(...) |
see above |
automation_queries.list_automations(run_store, *, status=None, cursor=None, limit=50) |
a Page(items, next_cursor) of automation summaries, newest first, with a cursor that survives restarts; archived automations stay listed; status filters on a value, a comma-separated string or a list |
automation_queries.automation_summary(controller_run) |
one summary: {automation_id, title, status, revision, trigger, context_mode, target, session_id, workspace_root, next_fire_at, retry_at, current_occurrence, occurrence_count, pending_occurrence, last_outcome, archived_at, created_at, updated_at} |
automation_queries.latest_occurrence(run_store, automation_id) |
the index row of the highest-numbered occurrence (its newest attempt), or None |
adopt_legacy_schedule_projection(run) |
a read-only summary of a legacy scheduled:* gateway root, marked legacy: true with capabilities pause, resume and cancel; legacy roots are never migrated |
Status is, in order of precedence: archived (the definition has archived_at, whatever state the controller
ended in), failed (the controller run failed, or was cancelled without being archived: it can never run again),
completed (the controller ended), paused, active. get_automation and list_automations share this one rule
(automations.models.automation_status).
next_fire_at is when the next occurrence will be admitted, decided exactly as the controller will decide it, so
a client never computes schedules itself. When the controller is parked it is the deadline of its wake wait: the
next tick, or the next attempt while an occurrence is in backoff (the summary then also sets retry_at). While an
occurrence runs it is the trigger adapter's answer on the persisted cursor: the next grid tick if it is still ahead,
otherwise the tick a coalesced admission will fire as soon as the running occurrence ends. It is None for a
manual trigger, when exhausted, and when the automation is not active (paused, archived, completed, failed).
current_occurrence is the occurrence in flight, {index, run_id, attempt, status: "admitted" | "running" |
"backoff"}, or None; clients read it instead of inferring "running" from the last outcome.
get_automation and the list summaries share both projections (automations.controller.next_fire_at,
current_occurrence).
list_automations(changed_since=...) is refused with ChangedSinceUnsupported (unsupported_feature): clients
poll complete pages.
Reading history is a pure read: it makes no provider or tool calls and writes nothing.
Runs, sessions and history¶
Automations add structure to the run index that every built-in store keeps (SQLite, JSON files, in-memory, and the offloading wrapper).
Run attribution¶
Each run index row carries four fields derived from the run's vars._meta when it is saved:
| Run | role |
session_kind |
automation_id |
occurrence_index |
|---|---|---|---|---|
controller (_meta.automation) |
controller |
automation |
its own id | — |
occurrence (_meta.occurrence) |
occurrence |
automation (growing) or occurrence (independent) |
the automation | N |
| child of an occurrence | descendant |
as its occurrence | the automation | N |
discussion (_meta.discussion) and its children |
discussion |
discussion |
the automation | N |
legacy gateway scheduled root (_meta.schedule.kind == "scheduled_run") |
legacy_schedule |
automation |
— | — |
| any other run | empty | chat |
— | — |
list_run_index(..., automation_id=, role=, session_kind=) filters on these fields; each filter takes a value, a
comma-separated string (session_kind="chat,discussion") or a list. Existing SQLite stores fill the columns once
when opened; the JSON store re-reads each run file once. Identity metadata (vars._meta.automation, .occurrence,
.discussion, .creation_digest) always stays inline: the offloading store never moves it to the artifact store.
Every row also carries workspace_root: the folder the run executes in, read from its top-level
vars["workspace_root"] as stored (host overrides and discussion folders included), only stripped of surrounding
whitespace and never resolved; None when the run has none. Existing SQLite stores fill it once when opened; the
JSON store re-reads each run file once. The offloading store never moves the key.
session_attribution(run_store, session_id) (abstractruntime.core.run_attribution) returns None for a session
with no runs, or {"kind": ...} with the session's most specific kind (discussion, then automation, then
occurrence, then chat). A discussion adds discussion_root_run_id, automation_id, occurrence_index,
revision, workspace_root, discussion (the root's _meta.discussion without the seed) and workspace_policy
(the root's workspace keys that later turns inherit). Every store exposes
session_kinds(session_id), which answers from an index without scanning runs.
Turn roots¶
A session's turns are its turn roots: runs without a parent, except automation controllers, plus automation
occurrences. list_run_index(root_only=True) returns turn roots, so an app that folds root runs into sessions shows
an automation's session as a chat whose turns are its occurrences.
select_session_turns(run_store, session_id, *, include_occurrences=True, until_ms=None, automation_id=None,
through_occurrence=None, include_drafts=False, limit=50) is the one definition of a session's turns, used by history
bundles and session replay. It returns turns oldest first and never includes child runs, automation controllers,
runtime-internal runs, legacy scheduled wrappers or (unless asked) draft-test runs. A retried occurrence is one turn,
its newest attempt. automation_id keeps only that automation's occurrences (other turns stay);
through_occurrence=N returns the history as it stood when occurrence N ran, however old N is, and raises
OccurrenceNotInSession when the session has no occurrence N, or SessionHistoryError when the store has no run
index to look it up in.
session_chat_messages(..., automation_id=None, through_occurrence=None, strict=False) replays those turns as
user/assistant message pairs under the session history window (the most recent HISTORY_REPLAY_MAX_TOKENS = 50,000
tokens of whole turns; see API). With strict=True it raises
SessionHistoryError (reason_code = "history_unavailable") instead of returning a partial history: a store without
a run index, a missing occurrence, or a discussion whose seed is missing or cannot be read. Automation admission and
discussion seeding always read strictly.
Storage guarantees¶
Automations rely on four store guarantees. Hosts that bring their own store must provide them.
- Create-if-absent.
RunStore.create_if_absent(run) -> (run, created)creates a run only if its id is free. It is implemented by the SQLite, JSON-file, in-memory and offloading stores. The JSON store publishes a fully written temp file with an atomic hard link, so an existing run file is never replaced (filesystems without hard links raise instead).Runtime.start(..., run_id=...)andSTART_SUBWORKFLOWwithpayload.run_iduse it: a start with the same identity (workflow, session, parent,vars._meta.occurrence,vars._meta.creation_digest) returns the existing run untouched, and a different identity raisesRunIdentityConflict(reason_code = "identity_conflict"). A store without the primitive raisesNotImplementedErroron an explicit-id start;store_supports_create_if_absent(store)checks a store first. Recovery after a process crash is covered; power-loss durability is not claimed. - Per-run tick lock.
run_mutation_lock(run_id)is held byRuntime.tickfor the whole tick, byRuntime.resumefor its commit, and by every automation command. A host may take it around its own read-modify-save of a run; it must not modify a controller'svars._meta.automationorvars._runtime.automationdirectly, becauseapply_automation_commandis the only supported writer of automation state. The lock is re-entrant and per process. - One writer process per store. v1 supports one process that ticks, resumes and commands runs on a given store.
Several store objects or read-only processes on one JSON run folder stay consistent: every creation and deletion is
appended to a small creation journal (
.runs_created.log), and each store applies new journal lines before a session or children lookup, so a discussion created through one store object is enforced read-only by another.JsonFileRunStore.warm_session_index()builds the session and children indexes at startup instead of on the first chat. The JSON store also removes run temp files older than 10 minutes, left behind by a crash, when it opens. - A run index for sessions.
Runtime.startchecks the attribution of every root run that names a session. A store without a run index (nosession_kindsand nolist_run_index) cannot answer, and the start is refused withSessionAttributionErrorrather than risk starting an unanchored run in a discussion session. Strict history reads andlatest_occurrence(which uses the store'slatest_occurrence_row) need the index too.
Crash safety¶
Each transition, whether a command or a controller step, is a decision:
- Reconcile any unfinished decision.
- Look up the decision's key exactly (
find_by_idempotency_key). If it exists, the decision was already made and nothing new is appended. - Decide from the persisted state.
- Save the intent (
_runtime.automation.intent). - Append the record, which carries the full state change and its
state_version. - Apply the change and save.
After a crash, the next step either applies the recorded change or drops the intent, so a crash at any point leaves exactly one record and one application. A parent that crashes after starting a child with an explicit id, but before saving its wait, finds the same child on replay and waits on it again; if the child already finished, the parent receives its result directly.
Limits in v1¶
- Schedules are fixed UTC intervals (
s,m,h,d, at most366d). There are no cron expressions, calendar months, time zones or daylight-saving rules, andanchormust equalstart_at. - The shipped trigger sources are
schedule@1,manual@1andemail.received@1. There is no generic external event source yet. - Occurrences run one at a time (
serial), missed ticks coalesce, and a failed occurrence never stops the automation (failure: "continue"). These policies cannot be changed. - Growing history is the most recent whole turns within the configured token budget (50,000 tokens by default) and is not summarized automatically; older turns drop out of the replay (they stay in the store).
- Tools outside
TOOL_EFFECT_CLASSES, such as third-party MCP tools, still ask for approval underauto. - Retries repeat external effects.
- One writer process per store; the mutation lock does not coordinate separate processes.
list_automationshas no change cursor (changed_sinceis refused); poll complete pages.- Legacy gateway
scheduled:*roots are listed read-only and are never migrated.
See also¶
- api.md: the import surface
- architecture.md: where automations sit in the runtime
- tool-approval.md: tool risk tiers and the run policy
- email.md: email accounts, the
email.received@1trigger and the send-email action - faq.md: common questions
- troubleshooting.md: symptom-oriented fixes
Growing context limit¶
Choose Growing to set Max growing context (tokens) when creating or editing an
automation. The default is 50,000; enter 30000 for a 30,000-token history budget.
The limit is hidden for Independent runs. Changing it affects subsequent occurrences;
already admitted occurrences retain their history for retries. History retains whole turns,
including the newest turn even when that turn alone exceeds the budget.
The API field is context.growing.max_tokens, a positive integer. Existing definitions
that omit it retain the 50,000-token default.