GUIDES

Scheduling tasks

An agent that only acts while someone is watching is half an agent. Let users schedule work and have it run on its own.

Turn scheduling on

Scheduling is a set of reserved tools the agent can call. Enable it in your project settings and four tools become available to the model: schedule_task, list_scheduled_tasks, update_scheduled_task, and cancel_scheduled_task. You do not wire anything up. The agent decides when to use them from what the user asks.

There is a second step, and it is the one people miss. A scheduled run may only use write tools that were pre-authorised for it, and the pool it can draw from is the project's writable tools list, which starts empty. Until an admin adds tools to it in the console, no scheduled task on the project can be given a write action. Enabling scheduling alone gets you tasks that can read, summarise and report, and nothing else.

With an empty writable-tools list, a task the agent tries to create with pre-authorised writes is refused outright: "these tools can't run unattended on this project: …". A task created without them succeeds and then quietly has no write tools at run time. Add every action you want runs to perform to the project list first, then the agent can pre-authorise it on an individual task.

Project defaults, all editable in settings:

FieldTypeDescription
enabledfalseOff until you turn it on. The reserved tools are not offered to the model while it is off, and due tasks are skipped rather than run.
writable_tools[] (empty)The project-wide pool of write actions a scheduled run may be authorised for. Empty means no scheduled task can write anything.
max_tasks_per_user10Counts tasks that are active, paused or running. Done and cancelled tasks do not count.
min_interval_minutes15The smallest gap allowed between two fires of a cron task. Measured from the first two occurrences of the expression.
max_runs_default0The run cap applied when the agent does not name one. 0 means unlimited.
Scheduling runs work under the user's identity, so it needs signed identity mode. A project in open mode never fires a due task, because the caller-supplied id there is forgeable and the run could not safely act as anyone. See the identity guide to switch it on.
Scheduling is PostgreSQL-only. Without DATABASE_URL the task tables do not exist: creating a task returns nothing, listing returns an empty array, and the engine never finds anything due. It is a production feature, not something you will see working on the local SQLite fallback.

How users schedule from chat

Users do not fill in a form. They just say what they want, and the agent turns it into a task.

text
"Every weekday at 9am, check my open orders and message me if any are stuck."
"Remind me to review the Q3 numbers next Monday morning."
"Run the weekly summary every Friday at 5pm my time, but only for the next month."

Under the hood each of those becomes a scheduled task with a schedule, a timezone, and a run cap.

FieldTypeDescription
One-offrun_at (ISO)A single run at a specific instant, e.g. 2026-09-01T09:00:00. Must be in the future, or creation is refused.
Recurring5-field cronStandard cron, e.g. 0 9 * * 1-5 for 9am on weekdays. Exactly one of cron or run_at, never both.
TimezoneIANAe.g. Asia/Riyadh. The schedule is evaluated in the user's zone, not the server's. Omitted means UTC.
max_runsintegerCap the number of runs. 0 means unlimited. When the agent does not set one, the project default applies.

Creation is validated before anything is stored, and each refusal comes back to the model as plain text it relays to the user:

FieldTypeDescription
Too frequent15 min"that schedule runs too often — the minimum interval is 15 minutes". A cron like */5 * * * * is refused at creation.
Too many tasks10"you already have the maximum of 10 scheduled tasks — cancel one first". Paused tasks still occupy a slot.
Bad cron—"that recurring schedule isn't a valid cron expression".
Past time—"the one-time run time must be in the future".
Unauthorised write—"these tools can't run unattended on this project: …" when the agent asks for a write tool that is not in the project's writable-tools list.

How the time and zone are decided

There is no date picker. The agent reads the user's sentence and resolves it — so it needs to know what time it is and where the user is. Agentifys tells it, on every turn:

FieldTypeDescription
The clockautomaticThe current UTC time is injected into the system prompt on every turn. You do not configure this.
user_context.timezonesignedBest source. If your backend signs a timezone into the identity, it wins — it is verified.
client_tzbody fieldThe browser's zone. The embedded widget sends this automatically. Used only to phrase the prompt, never to authorise anything.
Neither—The agent is told the zone is UNKNOWN and instructed to ask the user before scheduling, rather than assuming UTC.
Zones must be IANA Area/City names — Asia/Riyadh, Europe/London, America/New_York. Riyadh, GMT+3 and AST are not valid and are rejected when the task is created. Etc/GMT+3 is rejected too, even though it is a real zone: the POSIX convention inverts its sign, so Etc/GMT+3 means UTC−3, and it ignores daylight saving. UTC and Etc/UTC are accepted. A recurring task stores the zone, so 0 9 * * 1-5 means 9am local all year — daylight saving is handled for you.
If the agent omits the timezone entirely, the task is created in UTC without complaint. The tool description tells the model to ask rather than guess, but a model can still skip that. Sign a timezone into your user identity and the question never arises.
Calling /v1/chat yourself instead of embedding the widget? Send client_tz as a top-level body field. Never add it inside a signed user_context — that breaks the signature.

One asymmetry is worth knowing before a user asks for it. A recurring task can be moved to another timezone on its own, because the cron expression is a local time. A one-off task cannot: it was stored as an absolute instant, so the original local time is gone, and the agent is told to ask for a new run_at as well.

What a run actually carries

When a task fires there is no browser open and no user typing. The run is executed as the owning user, with their private knowledge and their vault credentials. It is close to being them, but it is not the same as them being present, and the gap is where the surprises live.

Carried into the run:

FieldTypeDescription
user.idliveThe owning user id, used to scope everything below.
name / email / role / companysnapshotCopied from the identity at the moment the task was created, and never refreshed. If the user changes company next month, the run still uses the old value.
{{user.creds.<name>}}liveVault credentials are decrypted fresh on every run, so a tool authenticated with a stored secret behaves exactly as it does in live chat.
Private knowledgeliveThe user:<id> retrieval audience is included, so the run can read that user's own documents alongside the shared pool.
TimezonetaskThe task's own zone becomes the run's clock, so a report that says "today" means today where the user lives.
Not carried into the run: {{user.auth_token}} and {{user.data.*}}. The auth token is a live session token — it is stripped before the user record is ever written, and it is not in the task snapshot. In a tool header it resolves to an empty string, so Authorization: Bearer {{user.auth_token}} is sent as Authorization: Bearer and your API sees an unauthenticated call rather than an error. Custom fields are the same story: {{user.data.plan}} is left as literal text in the system prompt and collapses to an empty string in a tool header or parameter. If a tool has to work unattended, authenticate it with a vault credential, not with the session token.
The same gap reaches your policies. A policy condition that tests user.data.* sees a missing value during a run, and a missing value is not simply "no match": under eq it stringifies to the literal text None, under neq it is therefore true, under gt and lt it reads as 0, and under contains as the empty string. A policy that grants a tool to paying plans will not grant it inside a scheduled run, and one that reads user.data.credits lt 10 fires on every run. Conditions on user.role or user.company still work, because those are in the snapshot.

Results do not vanish into a log. A successful run is held and surfaced to the user on their next live turn, so a Friday summary is waiting for them Monday morning. Up to five unseen results are handed to the agent at the start of that turn and marked seen at the same moment — whether or not the agent chooses to mention them. Only runs that finished ok are surfaced this way; a failed run waits in the My-tasks panel instead.

Write actions during a run

Before the model is called, an unattended run is handed a reduced tool list: every tool marked requires confirmation that is not pre-authorised on the task is removed. The model is never offered an action nobody is present to approve, and a pre-authorised write runs straight through with no prompt.

Since the project's writable-tools list starts empty, the practical default is a run with no write tools at all. Nothing errors. The model works with what it has, and finishes with a report saying what it could not do. That run is recorded as a normal, successful one, and the missing write shows up only in the text of the result. Read the result text before assuming a write happened, and check the task's allowed actions if a recurring task keeps producing reports instead of doing the work.

How a run ends

Every attempt writes a run record, and the task's last_run_status and last_error carry the outcome. There are five ways a run ends.

FieldTypeDescription
okrun statusThe agent turn completed. The output is held for the user and the task advances to its next slot.
error / timeoutrun statusA run has a 120-second wall clock. Past it the run is abandoned with last_error "run timed out". Any other exception is recorded the same way, with the message.
skipped_budgetrun statusThe project hit its monthly token budget before the run started. Nothing is spent and nothing is executed. last_error is "monthly budget exhausted".
cancelledrun statusThe owning user no longer exists in the project. The run does not happen and the task is cancelled for good, with last_error "user no longer exists".
blockeddefinedReserved for a run stopped at an unauthorised write. The current engine removes those tools before the model sees them, so no run is recorded with this status today.
A one-off task skipped for budget never runs. The budget gate advances the task the same way a completed attempt would, and for a once task that means status done with no next run. The reminder is gone; raising the budget afterwards does not bring it back. A cron task survives the same gate and simply tries again at its next slot.
A failed run still costs a run. run_count is incremented after every attempt that reached the agent, including one that timed out or errored, and it is compared against max_runs. A task capped at 5 runs that errors five times is marked done without ever producing a result. Budget skips are the exception — they do not increment the count.

Two more things the engine does on its own. A task whose project has scheduling switched off, or that has dropped out of signed identity mode, is passed over on each tick and waits rather than failing. And if the backend dies mid-run, the task is left running until the next startup, which resets it to active.

The My-tasks panel and API

The widget ships a My tasks panel where a user can see their scheduled tasks, what ran, and cancel anything they no longer want. Its calendar button appears on its own once the project is in signed mode with scheduling enabled; you can also open it from your own UI with ERA.openTasks(). The panel is backed by the scheduled-tasks API, which you can also call directly with the user's signed identity in the X-Era-User header.

GET/v1/scheduled-tasks
POST/v1/scheduled-tasks/seen
PATCH/v1/scheduled-tasks/{task_id}
DELETE/v1/scheduled-tasks/{task_id}

GET lists a user's tasks and results, POST /seen marks results seen (body {user_context}), PATCH /{task_id} pauses or resumes a task (body {user_context, status:"active"|"paused"}), and DELETE /{task_id} cancels one. The panel is per user, so a person only ever sees and controls their own tasks.

The list route hides cancelled tasks. A task you cancel through DELETE disappears from GET /v1/scheduled-tasks immediately rather than coming back with status cancelled, so do not poll for that status to confirm the cancellation — treat the task vanishing from the list as the confirmation. The same applies to a task the engine cancelled because its user was deleted. Only the console's admin view shows cancelled tasks.
There is no public route for run history. GET /v1/scheduled-tasks returns only the most recent run per task, as last_result truncated to 1000 characters and last_error truncated to 500. Full history — up to 50 runs, each with its tokens, tools used and untruncated output — is available only in the console, under the task in your project's scheduled tasks. If your product needs to show a user their own history, capture each result as it is delivered.