unifyai/unify runtime. The heart of it
is the
unify/task_scheduler
package — start with its
README,
which this page expands on.
Three design commitments shape everything below:
- Tasks are rows, not processes. A task is a durable record in a storage context; execution is something that happens to the row, and every lifecycle transition is a field update you can inspect.
- Tasks are independent. There is no queue and no chaining between tasks — each fires on its own clock or trigger, and concurrent runs are normal.
- The description is the program (by default). Unless a task has been
promoted to a certified symbolic entrypoint, execution means handing the
task’s natural-language
descriptionto the actor.
The task model

unify/task_scheduler/types/task.py.
TaskBase is the writable payload; Task adds identity:
| Field | Meaning |
|---|---|
name, description | Title and the detailed instruction. For agentic tasks the description is what gets executed. |
status | Lifecycle state — see the state machine below. |
schedule | Optional Schedule holding start_at. |
trigger | Optional Trigger — an inbound communication event that starts the task. |
repeat | Optional list of RepeatPatterns. |
priority, deadline | Priority (low→urgent) and an optional due-by datetime — distinct from start_at, which is when work begins. |
destination | Where the row lives: personal by default, or a shared team pool via team:<id> (see storage). |
response_policy | Freeform contact-handling policy for the run; overrides contact-level policies where they conflict. |
entrypoint | None = agentic execution; a function_id = symbolic execution via a stored function (see certification). |
offline | Delivery lane only — True runs in the headless lane without waking a live session. Orthogonal to agentic/symbolic. |
activated_by | ActivatedBy (schedule / trigger / explicit) — set automatically at activation, never directly editable. |
info | Execution summary written on completion. |
- Schedule XOR trigger. A model validator rejects any row with both: “A task cannot have both schedule and trigger.” A task either fires on the clock or fires on an event, never both.
task_idvsinstance_id. One logical task can execute many times. Thetask_idis stable; each re-arm clones a fresh row with the nextinstance_id. “The weekly recap” is onetask_idwith a growing family of instances, each with its own status and history.
Recurrence: a typed RRULE subset
repetition.py
defines RepeatPattern as “a small subset of RFC-5545 RRULE expressed as
first-class fields”: frequency (MINUTELY through YEARLY), interval,
weekdays (valid only with weekly frequency — a validator enforces it),
time_of_day, and the two terminators count and until. Two functions do
the real work:
normalize_repeat_patternscollapses evenly-spaced daily slot lists into a single minutely rule.next_repeated_start_at(previous_start, patterns, current_occurrence_index, now)computes the earliest future occurrence, honoringcount/until— this is what re-arm cloning calls to schedule the next instance.
The lifecycle

Status
is a six-value enum: scheduled, triggerable, active, completed,
cancelled, failed. There is no separate FSM module — transitions are
enforced by the scheduler’s validation funnel:
- Creation lands in
scheduled(hasschedule.start_at) ortriggerable(hastrigger); writingactivedirectly on create or update is forbidden. - Only
TaskScheduler.executemoves a row toactive, stampingactivated_byas it does. - Terminal states come from the run itself:
ActiveTask.result()writescompleted(plus an LLM-generatedinfosummary) orfailed;stop()and cancel-classified interjections writecancelled.
execute
starts a triggerable task, it first clones an identical new triggerable
instance (so the trigger stays armed while the current instance runs), then
marks the current one active. Recurring scheduled tasks clone the next
instance with schedule.start_at = next_repeated_start_at(...) — and if
there is no next occurrence (a count/until boundary), no clone is made
and the series ends. See _clone_task_instance in
task_scheduler.py.
TaskScheduler: the manager
TaskScheduler
(extending the abstract contracts in
base.py)
is the manager the actor reaches through primitives.tasks.*. Its public
surface is deliberately small:
| Method | What it does |
|---|---|
ask(text) | Read-only questions, answered by an LLM tool loop over the internal query tools. Returns a SteerableToolHandle. |
update(text) | Mutations via an LLM tool loop over the internal mutation tools. Returns a SteerableToolHandle. |
execute(task_id, *, trigger_attempt_token=None) | ”Start one runnable task instance and return its live handle.” Unlike ask/update, this is not a tool loop — it returns the ActiveTask handle directly. |
create_task(name=..., description=..., destination=None) | Thin non-LLM wrapper over row creation. |
ask/update loops, the LLM composes internal tools:
_filter_tasks (boolean expressions), _search_tasks (semantic vector
search), _reduce (aggregates), _create_task/_create_tasks,
_update_task, _update_task_instance, _update_task_status,
_delete_task, _cancel_tasks, and _get_task_or_raise. The update loop
also wires in ContactManager.ask, so “when Alice emails, text me” can
resolve Alice to a contact id without leaving the loop. When the caller
provides clarification queues, a request_clarification tool is injected
too — this is how ambiguous edits (“move it to 8” — a.m.? which task?)
bubble a question to the user instead of guessing.
Every write funnels through the same validators regardless of entry point:
schedule-XOR-trigger, scheduled requires start_at, triggerable
requires trigger, no direct active writes, and active rows resist most
mutation (_ensure_not_active_task). Prompts for the loops live in
prompt_builders.py.
Storage and team routing
All I/O goes throughTasksStore,
which “centralises reads, writes, field management, and metrics.” Four
contexts (all under the Assistants project) make up the task system’s
durable state:
| Context | Role |
|---|---|
Tasks | The user-authored task rows — the scheduler’s primary read/write surface. |
Tasks/Activations | Machine-projected activation rows (when should what fire) — read-only inside the runtime. |
Tasks/Runs | Durable run records, one per execution. |
Tasks/OutboundOperations | Idempotency ledger for offline sends. |
destination="team:<id>" is written under Teams/<id>/Tasks via
ContextRegistry.write_root, while reads federate across the personal root
plus every team the assistant belongs to (ContextRegistry.read_roots).
During a run, the task’s destination is exported as the TASK_DESTINATION
environment variable so writes made by the run (secrets lookups, new
rows) inherit the task’s scope.
Activation: three lanes

TaskScheduler.execute.
Scheduled (live)
Activations are projected intoTasks/Activations; a timer fires (Cloud
Tasks in the hosted deployment, or the in-process
LocalActivationScheduler
for self-host — “an asyncio-timer supervisor that fires scheduled tasks
in-process”, stateless across restarts, re-arming from wall-clock
next_due_at on boot). The timer publishes a
TaskDue
event carrying task_id, scheduled_for, an activation_revision, and a
visibility_policy defaulting to silent_by_default.
The handler in
task_activation.py
then: validates the delivery (validate_task_due_activation rejects stale
revisions, mismatched scheduled_for, and revoked team memberships),
records run provenance, and auto-starts the run via
scheduler.execute(..., _activated_by=ActivatedBy.schedule) — registering
the resulting handle in the ConversationManager’s in-flight actions so the
user (and the brain) can steer it. The silent_by_default policy is what
produces the user-facing behavior documented in
How tasks run: the run’s notification tells the brain to
“work silently unless you genuinely need the user.” If the runtime was
asleep, the wake reason is replayed on startup so a TaskDue never falls
through the crack of a cold boot.
Triggered (live)
Trigger matching is deliberately two-stage:- Mechanical — when an inbound communication arrives, the event
handlers call
_surface_trigger_task_candidates, which lists armed trigger activations for that medium and filters by the trigger’sfrom_contact_ids/omit_contact_ids. Cheap, deterministic, no LLM. - Semantic — surviving candidates are surfaced to the slow brain as a
notification: “Semantic judgement is still pending. Decide whether this
communication truly satisfies any candidate…” If the brain agrees, it
calls the exact
primitives.tasks.execute(task_id=..., trigger_attempt_token="...")line it was handed — the attempt token keeps the triggering inbound attached to the run it caused.
Offline (headless)
Tasks withoffline=True skip the live session entirely. The hosted path
dispatches a job; self-host uses
LocalOfflineDispatcher,
which spawns python -m unify.task_scheduler.offline_runner as a
subprocess. The
offline_runner
is “intentionally small and procedural”: it reads its contract from
environment variables (built by
offline_runner_contract.py,
shared with the hosted control plane), populates session details, runs
TaskScheduler.execute against a fresh CodeActActor, and persists the
terminal run state. Two behavioral differences from live runs: there is no
ConversationManager to wake, and clarifications are disabled — the run’s
guidelines say plainly, “do not ask the user for live clarification.”
Execution: ActiveTask
Whichever lane fired, execute wraps the run in an
ActiveTask
— “a thin wrapper around an actor-backed active plan handle, keeping the
corresponding Tasks row in sync.” Under the hood it resolves an execution
delegate from
task_execution_context.py
(the live ConversationManager’s actor, or the offline runner’s) and falls
back to a plain actor.act(...).
ActiveTask is a full steerable handle — the same steering contract as
every other long-running operation in the runtime:
pause()/resume()delegate straight to the actor.interject(message)first routes throughclassify_steering_intent, a small LLM router labeling the interjectioncancel/continue/ other — so “actually stop that” cancels the task row while “also include refunds” flows into the running plan.stop()marks the rowcancelled;result()marks itcompleted(writing theinfosummary) orfailed, and updates theTasks/Runsrecord either way.
Symbolic entrypoints and offline certification
Agentic execution re-plans from the description every run. For recurring work that has proven itself, the system can lock in a symbolic executor: a stored function from the FunctionManager whosefunction_id becomes the task’s entrypoint.
The hook is wired at execution time: when a repeating or triggered agentic
task runs, TaskScheduler._build_task_entrypoint_review attaches a
post-run review context, and the actor (in
code_act_actor.py)
gains two extra tools:
attach_entrypoint_to_recurring_task(function_id, rationale, equivalence_manifest, anti_oversimplification_checklist)— records the function as a candidate on future instances. It does not change delivery; future runs still execute live, now via the stored function.submit_offline_certification_evidence(function_id, certification_evidence, promotion_rationale)— the gate to the headless lane. Promotion tooffline=Truedemands a structured evidence dossier: arisk_classification(onlysafe_noop,read_only, andidempotent_effectfulare promotable), input and equivalence contracts, a managed-primitive contract (no ad-hoc replacements of platform primitives), side-effect, idempotency, bounded cost, failure, and observability contracts, plus six explicit boolean attestations. The scheduler’s_offline_promotion_rejection_reasonsenforces the checklist, and there are at most two revision attempts.
Local vs hosted scheduling
The package cleanly splits what fires from what decides when:- Hosted: the closed-source control plane owns timing (Cloud Tasks) and
job creation; the runtime uses a
NoopMaterializerand just receivesTaskDueevents. - Self-host / local:
settings.pyauto-detects the environment (LOCAL_SCHEDULER_ENABLED), andmaterializer.pybuilds theLocalActivationScheduler(pollingTasks/Activationson a configurable interval, default 60s) plus the subprocess-based offline dispatcher. Same events, same semantics, single daemon.
Testing and extending
For deterministic tests and demos there is a storage-free drop-in,SimulatedTaskScheduler,
with the same surface as the real manager. The test suite under
tests/task_scheduler/
doubles as behavioral documentation — good entry points:
test_trigger.py— trigger creation, schedule×trigger exclusion, clone-on-execute re-arm.test_repetition.py—next_repeated_start_at, recurrence cloning, offline promotion.test_execute.py— execution, provenance, trigger tokens, entrypoint review.test_local_scheduler_integration.py— end-to-end local timer →TaskDue.test_offline_runner.py— the headless lane’s lifecycle.
ask/update loops; new
activation sources belong as events handled in task_activation.py; new
execution styles belong behind the entrypoint field and the
certification gate — and everything durable belongs in the four Tasks*
contexts, never in process state.