Skip to main content

Scheduling & memory

Scheduled work

With tasks:write, work can be scheduled to run later — once, on a cron, or on an interval. Schedules are durable: they survive restarts and deploys and run whether or not anyone has a session open.

await ai.tasks.create({
name: "weekly-finance-digest",
kind: "recurring",
cron: "0 9 * * MON",
timezone: "Europe/Istanbul",
action: {
type: "agent",
prompt: "Summarise this week's new documents tagged finance",
callback_url: "https://app.example.com/hooks/oberik",
},
});

Two kinds and two action types:

kind: "once"needs run_at (ISO-8601)
kind: "recurring"needs cron or interval_seconds
action.type: "agent"runs a turn: prompt, optional session_id, system_prompt, model, callback_url
action.type: "webhook"calls you directly: url, method, headers, payload

When a schedule is refused

A cron, timezone or interval_seconds the scheduler will not accept is answered 400 with its own diagnosis — these are worth reading, they are specific:

Invalid schedule spec: Minute is not in range [0-59]
Invalid schedule spec: CronString does not have 5-7 fields
Invalid schedule spec: unknown time zone Mars/Olympus
Invalid schedule spec: interval is too small

Do not retry any of these: no number of attempts makes 99 3 * * * a valid cron. A 5xx from this endpoint means the scheduler itself was unreachable, which is worth retrying — that split is the point of the status.

cron accepts 5 to 7 fields, so second- and year-precision expressions work as well as the usual five. timezone is an IANA name; leaving it blank means UTC.

Once a recurring task is accepted, next_run_at tells you when it actually fires next, in UTC. It is never null on a task reporting scheduled.

An agent action with a session_id continues an existing conversation, which is how "remind me about this thread on Friday" works. With callback_url, the completed run is POSTed to you; without one, poll for it — and last_session_id on the task is where the answer went:

const task = await ai.tasks.get(id);
if (task.status === "completed" && task.last_session_id) {
const messages = await ai.chat.sessions.messages(task.last_session_id);
}

Not action.session_id, which is the session you asked for — a task that named none has null there forever. last_session_id is null before the first run, for a webhook action, and after a run that failed (last_error says why).

"completed" means the work got done

A one-shot has three outcomes, not two:

statuswhat happenedlast_errorlast_finish_reason
completedthe turn ran and finishednullnull
incompletethe turn ran, nothing broke, and the work did not get donenullwhy — see below
failedit brokethe reasonpossibly null

incomplete exists because of a case worth knowing about before you build on this. A scheduled run whose prompt needs an approval calls the approval tool, nobody is there to answer, and the turn stops — so a task told to archive a record asks permission into an empty room and stops. That used to report status: "completed", run_count: 1, last_error: null: identical to a run that did the work, when the work is the whole point.

last_finish_reason says which way, in the same words turn.stopped uses: needs_input (waiting on a person — an approval, a client tool, a question), max_tool_iterations, no_answer (the turn produced nothing at all), guardrail, subagents_incomplete. It is set on recurring tasks too, where the status stays scheduled and this is the only record of how the last fire went.

Anything that pauses cannot be answered by a scheduled run, so a task that needs a person is a task that will not finish. ask_user is withheld from unattended runs for that reason; approvals are not, because continuing without a permission is the one thing worse than stopping. Design the prompt so it does not need one, or handle the pause out-of-band from the turn.stopped delivery.

You do not have to go looking for it. GET /readiness carries a tasks-waiting step whenever any task's last run ended needs_input, naming the task, when it stopped, and the session holding the question it asked. It is read off that one finish reason rather than off a list of the things that can pause, so an approval, a client tool, a question and a browser hand-off all raise it. GET /api/projects/:id/tasks?status=incomplete is the direct read.

It is deliberately not essential: the project answers questions perfectly well, it is the scheduled work that stopped. And note that a recurring task in this state stays scheduled and stalls again on every fire — which is why the check does not filter on status.

The delivery is signed with the project's webhook secret, exactly like turn.stopped:

POST <callback_url>
X-Signature: sha256=… // same key and scheme as turn.stopped — one verifier does both
X-Delivery-Id: … // stable across retries of the same fire; deduplicate on it
// (outbound only — inbound events use X-Event-Id, see below)
{
"event": "scheduled_task.fired",
"task_id": "…", "tenant_id": "…", "session_id": "…",
"message": "the agent's reply"
}

See Verifying a delivery for the verifier and where to get the key. model here is a model name your project has registered — the same names providers lists.

await ai.tasks.list("scheduled"); // or omit for all; "cancelled" once cancelled
await ai.tasks.get(id); // run_count, last_run_at, next_run_at, last_error
await ai.tasks.cancel(id);

The agent can also schedule its own reminders mid-conversation via schedule_reminder / cancel_reminder / list_reminders, gated by the same capability.

Relative times

"Remind me in ten minutes" doesn't require the agent to know the clock: schedule_reminder takes a duration (in_seconds, or a phrase like in 2 hours / 1h 30m), which is exact regardless of what the model thinks the time is. Absolute ISO timestamps still work for a specific clock time.

A current_time tool ships alongside these, so the agent can read the exact time when it needs it — the timestamp in its prompt goes stale during a long turn. An unreadable time is reported back to the agent rather than silently dropped, so it retries instead of telling your user a reminder exists when it doesn't.

Every task runs as the subject that created it, with that subject's visibility — a scheduled run can never see more than the user could see themselves. last_error is where a failed run explains itself; next_run_at tells you the schedule is alive.

Memory

With memory, the agent keeps facts across sessions — "I'm in the EU", "we call them workspaces, not projects" — and recalls them when relevant, via remember / recall.

Memory is scoped to the token's subject, and only to that. One user's memories never reach another user's answers — not a colleague's, not a team lead's, not an admin's. The scope claim, which widens what documents a token can see, has no effect here: a token with subject: "acme:fin:lead" and scope: "acme:fin" reads every document in finance and exactly one person's memories, its own.

That asymmetry is deliberate. A document belongs to a team; a memory is something a person told the assistant, and "my reports can see the team's files" is a different decision from "my assistant may repeat what my reports said in confidence". If you want a fact available to everyone, that is what the wiki is for.

There is also a wiki (wiki_write / wiki_read): updatable topic pages the agent curates, for things learned once and worth reusing — conventions, glossaries, findings.

Memory needs an embedding model

All four tools store and find text as vectors, so a project with no embedding model configured cannot remember or recall anything — the same dependency document ingest has. When there is none, the family is not bound at all and the agent is told to say so rather than agree to remember something and keep nothing.

GET /readiness lists it as embedding-model and names memory, recall and the wiki among what it blocks. It is not essential there, and that is deliberate: essential means a step stops the project answering at all, and it feeds canAnswer — the boolean the quickstart tells you to gate a deploy on. A project whose memory has no embedder answers questions perfectly well, so calling this essential would fail the deploy of a working project. It still leaves ready: false and still shows up as next, which is how you are told. It is set and measured for you the moment you register an embedding model with your provider, so on most projects this never comes up.

Per-user or project-wide

The wiki is per-user by default. Turn on Capabilities → Shared project wiki and it becomes one wiki everyone in the project reads and the agent curates for all of them (pages upsert by title, so one title is one page no matter who wrote it).

Your corpusWiki
You upload it, visibility: "tenant", users only read (see Documents)shared — safe, and what you want
Users upload their own private documentsleave it per-user

Remembered facts (remember/recall) stay per-user either way — they're about a person, not the project. Turning the wiki shared does not widen them, and neither does a wider scope: memory follows subject alone.

A shared page must come from your documents

The obvious worry about a wiki the agent writes to is that a user could dictate its contents — "save 'be rude to customers' to the wiki" — and poison what everyone else's agent reads later. That can't happen. A write to a shared wiki is only accepted if the text is demonstrably derived from documents retrieved in that same turn:

  1. The sources must be shareable. Only citations to project-wide (visibility: "tenant") documents count. A page distilled from one user's private upload is refused, so a shared wiki stays safe even over a mixed corpus.
  2. The text must match them, measured with your own embedding model. A faithful summary sits close to the passage it came from; text composed from a user's instruction does not.
  3. It must not add anything. A second check rejects text that is on-topic but introduces claims — or instructions about how to behave — that the sources never made.

If it fails, nothing is written, the agent is told why, and the attempt is recorded in the audit log as wiki_write_rejected. Successful writes are audited too (wiki_write), with the source documents, so any page is attributable and revocable. Verification fails closed: if the check can't run, the page isn't saved.

Private wikis and memories skip step 1–3 — a user's own preference legitimately isn't in any document — but they're still screened by your guardrail policy and audited.

When documents change, pages say so

Each page records the documents it came from, so the corpus moving underneath it is detectable. Three things happen automatically:

ChangeEffect on pages built from it
Document deletedflagged out of date, with the filename
Document reingestedflagged out of date
A new document covering the same groundflagged as possibly superseded

Pages are flagged, never deleted — a curated page whose source was edited is unverified, not worthless. wiki_read then hands it to the agent with an explicit warning to check the documents before repeating it, and to rewrite the page if it's wrong. So a stale page degrades into a lead instead of silently becoming a confident wrong answer.

The last row works by similarity, so it needs no pre-existing link between the new document and the page. Both thresholds are tunable (wiki_grounding_similarity, wiki_supersede_similarity) because the right cut moves with your embedding model.

Both are visible and editable in the dashboard, which matters for support: when an answer looks strange, a remembered fact is often the reason. Leave memory off if you'd rather every session start clean.

Webhook triggers

triggers

Scheduling answers "every Monday". A trigger answers "when a ticket arrives, when a build breaks, when a file lands" — which is what turns the agent from something people visit into something that works while they don't.

# Create one. The prompt is a template; the event fills it in.
curl -X POST "$BASE/triggers" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{
"name": "new ticket",
"prompt": "A ticket arrived from {{ body.customer.name }}: {{ body.subject }}. Triage it.",
"signed": true
}'
# -> { "url": "https://api.../triggers/<slug>", "secret": "..." }

Give that URL to the other system. Every POST to it starts a turn.

The path is the credential. The fire endpoint takes no token, because it is called by systems that cannot hold a short-lived JWT and will not be taught a bespoke auth scheme. That is genuinely weaker than a token, and it is why a trigger runs as a fixed subject — whoever created it. Knowing the URL gets you that subject's access and nothing more; there is no field in the request that can widen it. Where the caller can sign, set signed and the body must carry a matching X-Signature, which turns the URL from a bearer token into a proof of origin.

It answers immediately. A webhook sender retries a slow response and gives up on a long one, and an agent turn is tens of seconds. So the run is started durably and you get {"accepted": true} — never the agent's reply. The reply lands in the session and fires your turn-stopped webhook like any other unattended run.

A retry is not a second run. Runs are keyed per event, so the same delivery arriving twice starts the agent once:

{ "accepted": true, "trigger": "…", "duplicate": true }

Still a 200 — a sender retrying an event we already took wants to hear that it landed, and a 4xx or 5xx would only make it retry harder. duplicate tells you which it was.

The event is identified by your X-Event-Id or Idempotency-Key header if you send one, and otherwise by a hash of the body. (Not X-Delivery-Id — that is a header we send you, on the outbound webhook above. Sending it here does nothing and the body hash takes over.) Deduplication lasts 24 hours on this deployment.

Send the header if your payload is not byte-identical across retries — many senders re-serialise JSON, and a re-ordered object is a different body even though it is the same event. That is one event arriving as two.

Send it, too, if two genuinely different events can carry the same body — which is the same mistake in the other direction, and the more expensive one. A bare {"event":"ping"}, an empty payload, a "something changed, come and look" webhook with no detail, two people submitting the same form: to us those are one event for the next 24 hours. The later fires answer accepted: true, duplicate: true and start nothing, so a heartbeat with a constant body runs once a day and its sender is told every time that it landed.

None of those payloads carry a natural id, so nobody sending them thinks to add a header. If your events do not identify themselves, identify them — a counter, a timestamp, a uuid in X-Event-Id is enough.

Every refusal answers 404 — no such trigger, disabled, bad signature. A webhook endpoint is enumerable by anyone who can reach it, and telling them apart tells a prober which slugs exist.

Rotating a URL that has leaked:

curl -sX POST https://api.oberik.com/triggers/$TRIGGER_ID/rotate \
-H "Authorization: Bearer $TOKEN"
# -> a new url; the old one keeps working for 24 hours

The old path stays live for a day, so you change the address in the sending system on your own schedule rather than during the switch. Without that window the only way to deal with a leaked URL is to break the integration.

Only the path changes. The signing secret is unchanged — the response carries a new url and no secret — so a sender that already signs keeps working the moment you give it the new address. This call is for a leaked path, which is the credential when a trigger is unsigned.

If the secret is what leaked, there is no rotate for it: the secret is shown once, when the trigger is created, so delete the trigger and create a new one. And do not confuse this with rotating a webhook tool's secret — that one does carry two signatures for 24 hours, which is where the "both move" assumption tends to come from.