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:
status | what happened | last_error | last_finish_reason |
|---|---|---|---|
completed | the turn ran and finished | null | null |
incomplete | the turn ran, nothing broke, and the work did not get done | null | why — see below |
failed | it broke | the reason | possibly 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.
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 corpus | Wiki |
|---|---|
You upload it, visibility: "tenant", users only read (see Documents) | shared — safe, and what you want |
| Users upload their own private documents | leave 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:
- 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. - 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.
- 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:
| Change | Effect on pages built from it |
|---|---|
| Document deleted | flagged out of date, with the filename |
| Document reingested | flagged out of date |
| A new document covering the same ground | flagged 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.