Skip to content

Let a Bot work while nobody is watching - #18

Closed
jerelvelarde wants to merge 6 commits into
CopilotKit:mainfrom
jerelvelarde:feat/routines-and-webhook-triggers
Closed

Let a Bot work while nobody is watching#18
jerelvelarde wants to merge 6 commits into
CopilotKit:mainfrom
jerelvelarde:feat/routines-and-webhook-triggers

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Let a Bot work while nobody is watching

The problem

A Bot only ever acts because somebody typed to it. There is no way to say "every weekday at eight, check the overnight alerts and write me a summary", and no way for another system to hand a Bot a piece of work; server/src/db/schema/coworker.ts claimed in its docblock to hold routines and no such table existed.

A scheduled run is also an unattended run: nobody can take the wheel, answer a question, or notice the wrong button before the consequence arrives. The boundary could not see that difference, so one set of rules had to cover both a supervised conversation and a browser acting on its own at three in the morning.

The approach

A routine stores a prompt and a schedule, not a script. The thing scheduled is a conversation turn, so a routine stays exactly as capable as the Bot is, and the schedule is JSON rather than cron so a form can edit it.

The runner has no privileges of its own. It drives the Bot's run() server side with no browser in the loop and puts every tool call through the existing ComputerGateway, which is where policy, audit and the action have always happened — the browser ran those tools only to render them — so an unattended run takes the same path and leaves the same rows. It does not offer computer_request_help, computer_request_secret or the take-the-wheel handover, because a tool that waits ten minutes for an answer nobody will give is an unattended hang; a model reaching for one is told so rather than having the call dropped.

run.unattended is a required policy attribute, not an optional one. The CEL engine reads an expression it cannot evaluate as a match, so a sometimes-absent field would make run.unattended && intent == "activate" refuse every attended action too. The /admin/boundaries preset names the cost of using it: a routine that may not press anything also cannot press a Next button on a page it was only going to read.

A run is bounded twice, and a window nobody was awake for is recorded. Twenty turns stops a model re-snapshotting an unchanging page, a fifteen-minute deadline stops a stream that neither completes nor errors, and every tick closes out runs older than an hour left behind by a killed process. Without them a row stuck at running holds the one-live-run index — a partial unique index rather than a check in the scheduler — and the routine never fires again. A missed window is recorded, stamped with its own time so the record is idempotent; a window older than the routine is not, so a once written for a moment already gone never fires.

Deliveries arrive on a separate Bun server on its own port, which serves /health and /hooks/:endpointId, 404s everything else, and refuses an over-1MB body on the socket rather than after it is in memory. ROUTINE_SCHEDULER=off closes the clock, Run now and this listener together, because a restored production dump keeps its endpoint ids and secret hashes. Three gates guard a delivery: a bearer secret shown once and stored only as a hash; verification, where a new trigger keeps its first authenticated delivery as a sample later ones do not replace, and runs nothing until somebody has looked at it; and an optional event-type allowlist. Triggers are the deployment's exposure rather than one person's work, so that surface requires an administrator and is not scoped to a creator; anybody else has to ask for one.

What is not covered

  • UTC only, and ticks are a minute apart, so eight o'clock means within a minute of eight o'clock UTC.
  • A webhook trigger carrying its own prompt has no run history, having no routine_runs row; its evidence is the audit trail. Those deliveries are serialised per trigger inside the process, so two server processes would each allow one.
  • No transcript is kept. routine_runs.thread_id names the run's conversation; nothing persists the turns, so a person gets the summary and the audit rows.
  • The forms lag the API. A routine cannot be edited after creation, its form makes daily schedules only, and event types are settable only over HTTP; the PATCH route supports all of it.
  • No retry, no backoff, no catch-up. A failed run waits for its next window; a missed one is not made up.
  • Computer tools only, so a routine cannot reach an MCP tool or a packaged skill its Bot holds.
  • The prompt is truncated at 8,000 characters of rendered payload, with a note.
  • The runner is not driven against a live model in CI; its tests drive a fake AbstractAgent emitting AG-UI events.

Merge notes

This branch changes a shared contract. PolicyContext in server/src/computer/policy.ts gains run: { unattended: boolean }, required rather than optional for the fail-closed reason above, so a branch that constructs a context should add run: { unattended: false } rather than relax the field; policy-ask-a-person is the likeliest to meet this. server/src/audit.ts and the admin audit page are touched additively, and server/src/index.ts lifts the ComputerGateway into a named const so the runner shares that instance rather than a second one.

Verification

Gates ran against a dedicated database with the generated migration server/drizzle/0001_amusing_wild_child.sql applied; no hand-written SQL.

  • bun run format and format:check — clean
  • bun run lint — exit 0, with 24 pre-existing warnings in files this branch does not touch
  • bun run typecheck — exit 0 across app, server and worker
  • bun run test — 744 pass, 5 skip, 0 fail across 76 files. Main's baseline is 594, so this adds 150.
  • bun run build — exit 0

Seven new test files, each holding one thing down:

  • routine-schedule.test.ts — the arithmetic, including a window older than the routine
  • routine-scheduler.test.ts — the tick: a second claim losing, one bad routine not stopping the loop
  • routine-runner.test.ts — tool calls reaching the gateway, both stream spellings, the cap, the deadline
  • routine-store.integration.test.ts — the one-live-run index and the first sample surviving a later delivery, against a real database
  • webhook-trigger.test.ts — the three gates, and the secret's hashing and comparison
  • routine-receiver.test.ts — the public port over a real socket: size checks, 401/404/202/409
  • routine-routes.test.ts — a routine scoped to its owner; triggers requiring an administrator

By hand: with ROUTINE_SCHEDULER=off the webhook port refuses connections while the API still answers; with it on, a 20MB unauthenticated POST is refused 413, a first delivery is captured and starts nothing, and after it is confirmed a build.finished delivery produces a completed run whose computer_navigate calls appear in the trail as ordinary computer.action_allowed rows.

A Bot only ever acted because somebody typed to it. There was no way to say
"every weekday at eight, check the overnight alerts and write me a summary",
and no way for another system to hand a Bot a piece of work. The schema module
already claimed to hold routines and neither table existed. A coworker that can
only work while you watch is half a coworker.

Routines are stored as a prompt and a schedule rather than as a script, because
the thing being scheduled is a conversation turn: whatever the Bot would have
done had somebody typed this at eight o'clock. That keeps a routine exactly as
capable as the Bot is, and stops the table becoming a second, weaker way of
describing work.

The runner is the part that matters. A scheduled run happens server side with
no browser in the loop, and every tool call still goes through the existing
ComputerGateway. The browser executes the computer tools purely so it can
render them, but the decision, the audit row and the action have always
happened on the server, so an unattended run does not need a weaker path: it
needs the same one and it gets it. What it does not get is the tools that ask a
person for something, because there is no person; a model reaching for one is
told so plainly rather than having the call silently dropped.

An unattended run is exactly the condition a boundary exists for. PolicyContext
gains `run.unattended`, always present and never optional, so a deployment can
write `run.unattended && intent == "activate"` and let a routine read, browse
and write notes while forbidding it to press anything. Present on every context
because this engine reads an unevaluable expression as a match, so a field that
was sometimes absent would turn a rule about routines into a rule that refuses
every attended action too.

`missed` is a real status rather than a nicety. A laptop asleep at eight
o'clock did not do the eight o'clock work, and the two wrong answers are firing
it at noon as though nothing had happened and recording nothing at all. The
window is stamped with its own time, which is what makes recording a miss
idempotent rather than a row a minute until the next window.

One live run per routine is a partial unique index rather than a check in the
scheduler. Two ticks overlap, two processes both tick, and the check-then-insert
a careful loop would do has a gap in the middle; what fits through it is two
emails sent. The second claim loses rather than races.

Webhook deliveries arrive on their own Bun server on its own port, serving
/health and /hooks/:endpointId and answering 404 to everything else. This is the
one surface in the product meant to be reachable by a third party, and the way
to keep the rest of the API away from it is for the rest of the API not to be on
it. Secrets are bearer tokens shown once, stored as a SHA-256 and compared over
digests so the comparison is fixed-width and constant-time. A new trigger keeps
its first authenticated delivery as a sample and runs nothing until somebody has
looked at what actually arrived, which is the gate that catches a mistyped hook
before it starts real work.
The webhook page said the endpoint was "served on its own port, not on this
one", which is the important half of the sentence and not the useful half.
Somebody copying a path out of that page has no way to finish the URL, and the
answer is a variable they have not read yet.

It now names ROUTINE_WEBHOOK_PORT and says it defaults to one above the API's.
The port itself is still not printed, because this page cannot know the address
the listener is reachable at from outside: it binds to 127.0.0.1 and is
normally behind something.
Review of the routines branch found nine places where the code and its own
documentation disagreed. Every one of them is a promise a person would act on:
a switch that stops unattended work, a ceiling on what a stranger may send, a
sample somebody confirms, a run history that can be believed. They are fixed
here together because they are the same kind of defect.

ROUTINE_SCHEDULER=off now stops everything. It stopped the clock and left the
webhook receiver listening, because the receiver was started whenever a
computer was configured rather than when this deployment runs routines. That is
exactly backwards for the case the switch exists for: somebody restores a
production dump onto a laptop, the boot log says nothing fires on its own, and
the restored triggers keep their endpoint ids and their secret hashes, so the
sender that was already configured goes on driving Bots against real systems
unattended. Meanwhile Run now, which is a signed-in person asking, correctly
refused. One value now answers for the clock, Run now and the port alike.

The public port refuses an oversized delivery before it is in memory. The
ceiling was checked after `request.text()` had buffered the whole body, and Bun
was never told a limit, so its own 128MB default was the real one: an
unauthenticated caller could make the one internet-facing surface allocate a
hundred megabytes per request. Bun is told the ceiling, the declared length is
checked first so a sender gets this product's own sentence, and what arrived is
measured in bytes rather than UTF-16 code units, which an emoji-heavy payload
walks straight through.

The sample a person confirms is the first delivery, not the last. Every
delivery arriving before confirmation asks to be captured, and the column was
overwritten each time, so the payload somebody read on the page was not the
payload they confirmed a moment later — with a CI system posting every minute,
reliably not. The database keeps the first non-null now, which also settles the
race between two deliveries a second apart. The test that claimed this property
only passed because it hand-set a value the receiver never sends; it now drives
what the receiver actually does.

A routine no longer records a miss for a window that predates it. Writing
"every weekday at eight" at three in the afternoon produced, within a minute, a
run reading "Missed, nothing was running" and an audit row agreeing with it,
about a deployment that was running perfectly well. The decision takes the
routine's own age, and a window before it is neither run nor recorded. Inside
the grace period it still runs, because somebody writing that schedule at three
minutes past eight has just said what they want.

A run that never comes back no longer wedges its routine forever. Nothing
bounded a run in wall-clock time: the turn cap counts turns that finish, and a
stream that neither completes nor errors — an AG-UI Bot whose connection stalls
— left the promise unsettled with no socket timeout underneath it. The row
stayed `running`, held the one-live-run index, and the routine never fired
again from the clock, from Run now or from a delivery, with deleting it and its
history the only cure. Runs now have a deadline, and each tick closes out runs
older than a ceiling no live run can reach, which is the same wedge arriving by
a different road: a process killed mid-run.

A webhook trigger carrying its own prompt is serialised. It has no run row, so
the unique index that protects everything else had nothing to hold, and whoever
had the secret could start one unattended agent run per delivery by retrying in
a loop. One at a time per trigger, held in the process because there is no row
to hold it, and the sender is told 409 rather than ignored.

Triggers are an administrator's, and every administrator sees all of them. The
routes admitted any signed-in person while the only page that renders them is
behind the administrator guard, so an ordinary user could mint a publicly
reachable endpoint and then had nowhere to see or revoke it, and an
administrator reviewing this deployment's exposure was shown only what they had
personally created. A trigger is a fact about the deployment rather than about
whoever typed it. Confirming, changing and deleting one now leave audit rows,
which is the moment an inert endpoint starts doing real work and the two moments
a door closes, and a captured delivery is recorded as captured rather than as
refused, which is the wrong word for the one outcome the feature is designed
around.

The routines list reads one run per routine. It read every run of every listed
routine and kept the newest of each in JavaScript, on a page that refetches
every fifteen seconds, against a table that grows by one row a day per routine
forever. The database returns the newest of each now, which is the pattern the
tick already used and the docblock beside it already argued for.

Finally, `routine_runs.thread_id` says what it is. It was documented as the
whole conversation including every tool call, and an unattended run speaks to
the Bot directly rather than through the runtime that owns durable threads, so
nothing writes the turns anywhere: the id names the conversation and correlates
the run's own rows, and the docblock now says that and points at what a person
actually gets, which is the summary and the audit trail.
@jerelvelarde

Copy link
Copy Markdown
Contributor Author

Overlaps with jerel/phase0-server-side-computer-tools

That branch adds server/src/computer/tools.ts, which declares the computer tools once and executes
them on the server next to the gateway, and its docblock names this exact case: "It also put an
unattended run out of reach entirely, which is the wall a scheduled routine meets."

This branch's runner reaches the same conclusion independently and pays for it twice: server/src/routines/runner.ts
carries its own switch over computer_navigate, computer_click, computer_type, computer_key,
computer_scroll and the three file tools, each calling the same ComputerGateway methods.

Whichever lands second should delete its copy. The tool declarations belong in computer/tools.ts,
and the runner should hold only what is specific to running unattended: the turn loop, the wall-clock
deadline, and the refusal to offer computer_request_help, computer_request_secret or the
take-the-wheel handover, since a tool that waits ten minutes for an answer nobody will give is an
unattended hang.

The other half of this branch — the schedule arithmetic, the run records, the webhook receiver on its
own port, and run.unattended reaching the policy — does not overlap and is independent of that
sequencing.

davidmckayv
davidmckayv previously approved these changes Aug 19, 2026

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed against the code and driven end to end locally.

Ran it, not just read it. Clean database, 744 pass / 0 fail. Then in the browser: created a routine, pressed Run now, and it completed with The page title "Example Domain" from example.com has been written into notes.md. The trail carried routine.run_started, two computer.action_allowed rows and routine.run_finished, so an unattended run really does go through the gateway.

The webhook auth is the strongest part. sha256 with timingSafeEqual over fixed-length digests so a length mismatch cannot throw, the secret shown once and stored only as a hash, a recognisable prefix so secret scanners catch a leak, content-length refused before the body is read, loopback by default, its own listener, and a new trigger holding its first delivery as a sample. I could not find a way in.

One thing to add before this is done. The audit row does not record that nobody was watching. run.unattended reaches the policy, but the payload is bot / actor / action / file / decision, and I confirmed zero rows from my live run contain it. That is the one fact this feature adds to the risk of an action, and an investigator reading the trail cannot currently tell a 3am unattended write from a supervised one without correlating timestamps against routine_runs. Given the product claim is that the trail says what happened, I would put unattended on the row.

Small UX thing: on the routine form the selected days are not visually distinct. I clicked Mon expecting to select it and actually deselected it from the weekday default, and could not tell from the screen which state I was in.

Latent, not a bug today: plugins/store.ts hardcodes run: { unattended: false }. Correct now, since the runner offers computer tools only, and your comment says so. The day routines get MCP, every run.unattended rule silently stops applying to MCP calls. Worth a comment pointing at that, since the failure is silent.

Merge-order note: this adds 0001_amusing_wild_child.sql, and #15 and #20 each add a different 0001. I reproduced the collision by accident: applying all three to one database left it with webhook_subscriptions but no routines, and /api/routines returning 500. Whichever lands first takes the number; the other two need renumbering and a regenerated journal.

@jerelvelarde
jerelvelarde marked this pull request as ready for review August 19, 2026 23:49
Both sides added a required member to PolicyContext and a preset that reads it, so the policy now
carries repeat.count and run.unattended together, the boundaries page offers a rule for each with its
own cost line, and the gateway is built once with the repetition window folded in and shared with the
unattended runner as before.
@jerelvelarde

Copy link
Copy Markdown
Contributor Author

Migration numbering, for whoever merges

Main holds 0000_schema.sql only. Three of the open branches each generated a 0001:
#15 0001_gigantic_sumo.sql, #18 0001_amusing_wild_child.sql, #20 0001_clammy_crystal.sql.

They do not conflict against main today, so each merges clean on its own — but the first one merged
takes 0001
, and the other two then carry a migration number that already exists. Whichever goes
second and third should re-run bun run --filter server db:generate on top of the new main so the
file, the meta/_journal.json entry and the snapshot are renumbered together, rather than renaming
the file by hand.

#19 adds no migration and is unaffected.

…hook-triggers

# Conflicts:
#	server/drizzle/meta/0001_snapshot.json
#	server/drizzle/meta/_journal.json
#	server/src/app.ts
#	server/src/config.ts
#	server/src/index.ts
@davidmckayv

Copy link
Copy Markdown
Contributor

Closing this, and the reason is about where OpenBot runs rather than about the code.

OpenBot is meant to be deployed as several server processes behind a load balancer, serving a whole company, with people bringing their own agents to it. That is the target every feature has to hold up under. Any state that outlives a single request has to be shared, or the feature works on one box and quietly stops working the moment there are two, which is worse than not having it.

Most of this branch already meets that bar. routine_runs_one_active_idx is exactly the right mechanism: the claim is a database write with a unique index behind it, so a hundred replicas ticking their clocks produce one run and the losers are told rather than left guessing. That part is good work.

Two things do not meet it:

  • Webhook triggers that carry a prompt and have no routine behind them have no run row, so nothing serialises them. The guard is a Set in the process. One sender retrying in a loop starts one unattended agent run per replica, each holding a model stream and driving a browser.
  • The receiver is a second port, apiPort + 1, bound to 127.0.0.1. That is a second ingress rule on every server in the fleet, and it is off by default from anywhere but the box itself.

Both are solvable with what is already here: give the prompt-only path a row to claim like the routine path has, and mount the receiver on the main app rather than a second listener. Happy to take it again on that basis.

We are also fixing our own side of this. The gateway's snapshot cache has the same problem and is load-bearing for the action boundary, and two merged changes are being reverted for the same reason. The rule is going on the PR template so it is stated up front rather than discovered at review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants