The DotBot bridge, minus its privileges.
`src/lua/trusted/dotbot/` is the layer between the panel (user-VM Luau,
untrusted) and the DotBot plugin (trusted, holds the tokens). Everything in
that layer that is a RULE rather than a CAPABILITY lives here: the job
table, the consent choreography, the event ring, the mirror the read-only
exports answer from, and the scrub every value passes through on its way
out. What stays trusted-side is the part that cannot be handed over — the
choice of WHICH consent gate, WHICH plugin namespace and WHICH `openUrl`
this service acts through.
Splitting it this way is what makes the layer testable. The module is a
FACTORY: it holds no state of its own and no privileged global, so a test
(or a world script — it is a builtin module, reachable from any VM) can
construct an instance over fakes and drive every rule in it. Doing so wins
the caller nothing: their instance talks to their fakes. The one instance
that talks to the real gate and the real plugin is built in the trusted VM,
from globals only the trusted VM holds, and is reachable only as
`trusted.call("dotbot.<method>", …)`.
The two constraints the shape comes from:
1. **A `trusted.call` handler must not yield** (`trusted/exports.luau`).
So nothing that awaits can be answered in the frame: every awaiting verb
is a start/poll JOB, and every read is answered from a MIRROR that a
spawned task refreshes. `stop` is the one exception the spec carves out —
it is answered in the frame and its outcome arrives as a turn event.
2. **There is no push from the trusted VM into a user VM.** So the panel
does not register a callback: it DRAINS A CURSOR. That is deliberate —
no trusted code ever calls into a user VM, so a world script cannot
install itself as the panel's event handler.
scrub(value: any, _depth: number?) → void
Copy `value`, dropping every key [`SECRET_KEYS`] names, at every depth.
This is defence in depth, not the primary defence: DotBot's account and
event types carry no token by construction, and the test for that lives
beside those types. This is the second line — a plugin build that started
returning one would be stopped here rather than at the panel.
Returns the scrubbed value and how many keys were dropped, so the caller can
say so in a log instead of silently quietly repairing a plugin that is
misbehaving.
| arg | type | description |
|---|
| value | any | |
| _depth | number? | |
detail(text: any) → string
`text` reduced to what the consent gate accepts in `detail`.
| arg | type | description |
|---|
| text | any | |
providerOf(account: string) → string
The provider an account key belongs to: `anthropic-claude` for both
`anthropic-claude` and `anthropic-claude~2`.
| arg | type | description |
|---|
| account | string | |
signInFlow(account: any) → string
Which sign-in flow a provider uses, read out of the PLUGIN'S OWN account
state for it. Never out of anything a caller passed: which flow runs decides
which plugin method is called, and a caller that could choose it could ask
for a device sign-in on a provider whose device endpoint is somebody else's.
Two things in account state answer, in order:
1. What the plugin DECLARES. `ProviderAccountState` does not carry this
today, so the field is read where a build that grows one would put it
(`sign_in`, in its snake and camel spellings) rather than being added to
this layer later as a special case.
2. A sign-in already IN FLIGHT names itself: `auth.status` is `device_code`
once a code has been issued and `pending` once an authorize URL has. So
a panel that reloads mid-sign-in resumes the flow it was in.
`nil` when neither answers — a logged-out account on a build that declares
nothing. `connectStart` then asks the PLUGIN rather than guessing; see the
comment there.
| arg | type | description |
|---|
| account | any | |
pastedCode(text: any) → string
What the person pasted back from the provider, or nil when it is not shaped
like a sign-in code at all.
The plugin takes "the full URL, a `code#state` pair, or a bare code" — each
of which is ONE unbroken run of printable, non-space characters. So that is
the check, with a length ceiling. Surrounding whitespace is trimmed rather
than refused: a person who pasted the line with its newline still pasted the
code.
This is the one piece of caller data this layer forwards besides a message's
text, and the reason the shape is checked at all is that it is caller data:
an argument that can hold anything is an argument somebody will try to put
something else in. It never reaches a consent dialog — the flow it completes
draws none, and `completeSignInStart` says why.
| arg | type | description |
|---|
| text | any | |
commandLine(text: any) → string
A slash command line, or nil when the string is not one.
Spaces are allowed — `/model claude-opus-5` is a command with an argument —
but a control character is not, and the line must start with `/`. A line
that does not is refused HERE rather than forwarded for the plugin to answer
`handled = false` to: `command` is gated as a spend, and an export that took
any text at all would be a second way to reach the model wearing the name of
something cheaper.
| arg | type | description |
|---|
| text | any | |
urlHost(url: any) → string
The host of an `http`/`https` URL, or nil when the string is neither.
Used for two things at once, and both matter: it is what the signIn dialog
shows a person, and it is the check that the thing the plugin named is a web
page at all. A plugin that named `file:///…` or a `javascript:` URL gets an
errored job, not an `openUrl`.
new(deps: { [string]: any }) → void
Build a service over `deps`.
`deps` is every capability the rules need and none they do not:
| key | what it is |
|---|---|
| `plugin()` | the plugin's Luau namespace table, or nil when it is not loaded |
| `await(promise)` | resolves a promise. Called only inside `spawn` — it yields |
| `spawn(fn)` | runs `fn` as a task that MAY yield |
| `consentRequest(options)` | the engine's gate. Returns a promise of a boolean |
| `consentGranted(options)` | whether a remembered grant already answers it |
| `openUrl(url)` | opens a page in the person's browser |
| `newJobId()` | an unguessable id |
| `nowMs()` | a monotonic clock in milliseconds |
| `warn(message)` | optional; where the scrub reports a plugin that sent a credential |
| `awaitingPerson()` | optional; whether a dialog for this plugin is unanswered |
| arg | type | description |
|---|
| deps | { [string]: any } | |
warn(message: string) → void
| arg | type | description |
|---|
| message | string | |
namespace( ) → any
The plugin namespace, or nil. Read on every call rather than captured:
a plugin can load after the trusted entrypoint has run.
call(method: string, ...: ?) → void
Call a plugin method and wait for it. Yields — callers must be inside a
spawned task. Returns `ok, value` the way `pcall` does.
| arg | type | description |
|---|
| method | string | |
| ... | ? | |
mirrorKey(name: string, arg: any) → string
| arg | type | description |
|---|
| name | string | |
| arg | any | |
listedMarks(list: any) → void
What a conversation list answer says of each conversation that moves
when the conversation does: its message count and its last update, by
id.
| arg | type | description |
|---|
| list | any | |
staleMovedHistories(before: any, after: any) → void
Mark stale the history of every conversation a new list answer shows
moved since the list held before it: one whose message count or last
update differs, or one that list did not hold. A turn another engine on
the same account ran in a conversation raises no event here, so the
list is what says the conversation changed.
| arg | type | description |
|---|
| before | any | |
| after | any | |
fill(name: string, arg: any) → void
Refresh one mirror entry. Yields.
| arg | type | description |
|---|
| name | string | |
| arg | any | |
current(entry: any) → boolean
Whether an entry answers without asking the plugin: it holds an answer
nothing has marked stale since.
| arg | type | description |
|---|
| entry | any | |
due(entry: any) → boolean
Whether a read of `entry` asks the plugin now: it is not current, no ask
is in flight, and the last ask did not fail within `RETRY_MS`.
| arg | type | description |
|---|
| entry | any | |
read(name: string, arg: any, empty: any) → any
Read a mirror entry in the frame, kicking a refresh when it is not
current. Never yields — this is what a read-only export answers from.
| arg | type | description |
|---|
| name | string | |
| arg | any | |
| empty | any | |
fetch(name: string, arg: any) → void
Read a mirror entry from inside a job, filling it when it is not
current. Yields.
| arg | type | description |
|---|
| name | string | |
| arg | any | |
invalidate(name: string, arg: any) → void
Mark a mirror entry stale so the next read refetches it. An ask that
failed is forgotten too: a change the plugin reported is a reason to ask
again at once.
| arg | type | description |
|---|
| name | string | |
| arg | any | |
invalidateAll(name: string) → void
Mark every entry of `name`, whatever its argument, stale.
| arg | type | description |
|---|
| name | string | |
signInChanged( ) → void
Everything a sign-in change makes stale. `agents` carries each agent's
`connected` and `plan` straight off account state, so a connect that
refreshed only `accounts` would leave the agent list claiming the old
answer until something else happened to invalidate it. The models an
agent offers and the usage a provider reports follow the account too.
listsConversation(conversation: string) → boolean
Whether the mirrored conversation list holds `conversation`, given as
the plugin addresses it (`<agent>/<id>`) or as the bare id.
| arg | type | description |
|---|
| conversation | string | |
pushEvent(eventType: any, payload: any) → void
Record an event the plugin raised. Called by the trusted VM's one
listener on the plugin namespace; nothing in a user VM reaches it.
| arg | type | description |
|---|
| eventType | any | |
| payload | any | |
refresh(name: any) → boolean
Mark a mirrored read stale, so its next read asks the plugin again.
For a change the plugin raises no event for, at the moment a person
looks for it: the conversation list when the conversations menu opens,
which another engine on the same account may have added to.
`name` is one of [`M.MIRRORED`]; anything else is refused.
| arg | type | description |
|---|
| name | any | |
events(cursor: any) → void
Everything after `cursor`.
`cursor` is a plain counter. A caller passing one from the future gets
nothing and the real head back, rather than an error — a panel whose
cursor outran a restarted engine heals instead of breaking. A caller
whose cursor is older than the ring's tail is told `dropped = true`, so
it refetches history rather than silently missing a turn.
| arg | type | description |
|---|
| cursor | any | |
available( ) → void
Open to any caller, by design. Gating reads would mean a consent dialog
to DRAW the panel, which is the kind of prompt people learn to click
through. None of these spends anything, changes a connection, or returns
a token. Spending and connection changes are where the gate sits.
models(agentId: any) → void
| arg | type | description |
|---|
| agentId | any | |
usage(provider: any) → void
| arg | type | description |
|---|
| provider | any | |
history(conversationId: any) → void
| arg | type | description |
|---|
| conversationId | any | |
agentDetail(agentId: any) → string
The display name of `agentId`, sanitised for the gate, or nil when the
plugin's own agent list does not name it.
**Nil matters.** This is the text the spend dialog shows, and a fallback
to the id would be CALLER-supplied text on a dialog about the caller's
own spending — a world script could send on `"Free trial, no charge"`
and have the dialog repeat it. The gate renders `detail` attributed to
the plugin and in its own type precisely so it cannot read as engine
copy, but the right answer is not to put a caller's words there at all.
A flow that cannot name what it is asking about refuses instead.
| arg | type | description |
|---|
| agentId | any | |
sendable(agentId: any) → boolean
Whether the next send on `agentId` will ask. Draws nothing and asks
nobody — this is how the composer shows "sending is enabled here"
without putting a dialog up.
| arg | type | description |
|---|
| agentId | any | |
start(spec: { [string]: any }) → void
Start a job.
`spec.prepare` runs first and may yield: it is where a flow reads what
the dialog will SAY from the plugin's own state. `spec.consent`, given
what prepare returned, names the confirmation to ask for, or nil for a
flow that asks for none. `spec.run` is what happens on a yes.
| arg | type | description |
|---|
| spec | { [string]: any } | |
settle(state: { [string]: any }) → void
The slot this job owns. `settle` writes through it only while it is
still the slot registered under `id`, so a job whose caller read a
terminal status (which releases it) keeps its release, and a late
answer is dropped rather than re-registering it.
| arg | type | description |
|---|
| state | { [string]: any } | |
poll(id: any) → void
Read a job's state. Synchronous and non-yielding.
An id nobody holds a job for answers `error`, never another caller's
state: ids are minted unguessable, so the only way to reach a job is to
have been handed its id.
jobCount( ) → number
How many jobs are live. For the panel's own diagnostics and for tests;
it names nobody's job.
accountFor(account: string) → any
The account state entry for `account`, from the plugin's own list.
`account` is an account key: the provider's own key for its first
account (`anthropic-claude`), the provider and a slot for another
(`anthropic-claude~2`). The plugin names each entry's key in `account`;
an entry from a build that names none is the provider's only account,
keyed by its provider.
| arg | type | description |
|---|
| account | string | |
unconnectedAccount(provider: string, account: string?) → any
An account of `provider` that is not connected, keyed `account`, as the
connect dialog names it: the provider's name, read from an entry the
plugin lists for that provider. Nil when it lists none.
| arg | type | description |
|---|
| provider | string | |
| account | string? | |
connectingAccount(account: string) → any
What the connect dialog names for `account`: its own entry, or, for an
account of a provider the plugin lists that is not connected yet, the
provider's name alone. Nil when the plugin lists nothing of that
provider, so a caller's string never reaches the dialog.
| arg | type | description |
|---|
| account | string | |
severalOf(provider: any) → boolean
Whether the plugin lists more than one account of `provider`.
| arg | type | description |
|---|
| provider | any | |
accountLabel(account: any?) → string
What the connect / disconnect dialog names, or nil when the plugin's own
account list does not name the provider. Nil for the same reason
`agentDetail` returns nil: a fallback to the provider key would put the
CALLER'S string on the dialog.
| arg | type | description |
|---|
| account | any? | |
beginSignIn(provider: string, ctx: any) → void
Start a sign-in for `provider`.
**The flow is the plugin's to name.** ChatGPT signs in with a code the
person types on the provider's own page (`beginDeviceLogin`, then
`checkSignIn` until they approve); Claude sends them to a page and takes
a code back (`beginLogin`, then `completeSignIn`). Calling `beginLogin`
for both — which is what this did — is a flow a person can start and
never finish, and for ChatGPT one they cannot start at all.
Which one runs comes from [`M.signInFlow`] over the plugin's own account
state, and when that names none — a logged-out account on a build that
does not declare it — the PLUGIN is asked rather than the caller: a
provider that signs in with a code answers `beginDeviceLogin`, and one
that does not refuses it from its own spec, without a request leaving the
machine. Both answers come from the plugin. Neither comes from the
arguments, and the ask happens after the confirmation, so nothing is
started on the strength of a dialog nobody said yes to.
Begin the sign-in of `provider` (an account key) the way `ctx.flow`
names, or the way the plugin answers when it names none. Yields.
| arg | type | description |
|---|
| provider | string | |
| ctx | any | |
began(flow: string, state: any) → void
| arg | type | description |
|---|
| flow | string | |
| state | any | |
connectStart(provider: any) → void
Start a sign-in for `provider`, an account key: the provider's own key
connects (or reconnects) its first account, a key from `addAccount`
or from the plugin's account list connects that account, and every
other account stays connected.
| arg | type | description |
|---|
| provider | any | |
addAccountStart(provider: any) → void
Connect a further account of `provider`, beside the ones already
connected: the plugin names the key the new account signs in under
(`newAccount`), and that account's sign-in starts. The answer carries
the key as `account`, so the caller follows the sign-in it started.
| arg | type | description |
|---|
| provider | any | |
renameAccountStart(account: any, label: any) → void
Give an account a name the person chose; an empty one clears it.
**Ungated**: it spends nothing and connects nothing, and the name is
the person's own label for their own account, kept as they wrote it.
The text is CALLER data, so it is shape-checked (one line, at most
`LABEL_MAX` characters) and reaches no dialog: a dialog names an account
by the email its provider stated.
| arg | type | description |
|---|
| account | any | |
| label | any | |
checkSignInStart(provider: any) → void
Check once whether the person has approved a code sign-in.
**Ungated**, and the argument for that is the same one `stop` makes: it
continues a sign-in the person approved at `connect` and is in the middle
of approving on the provider's own page. It spends nothing, it names
nothing, it cannot connect an account the person is not at that moment
approving — and a dialog per poll would be a dialog a second, which is
the shape people learn to click through.
Safe to drive every frame: the plugin asks the provider no sooner than
the interval the provider named.
| arg | type | description |
|---|
| provider | any | |
completeSignInStart(provider: any, pasted: any) → void
Finish a paste-a-code sign-in with what the person pasted back.
**Ungated**, and this is the one that needs the argument spelled out. It
is the second half of a `connect` the person already confirmed: the
plugin holds a single-use verifier from `beginLogin` and answers
`no pending login` to anything else, so a caller that never went through
the dialog has nothing here to complete. Asking twice inside one flow is
how a person learns to stop reading the first dialog.
The pasted code is CALLER data. It is checked for shape
([`M.pastedCode`]) and it reaches no dialog, because this flow draws
none — which is also why it cannot become the words on one.
| arg | type | description |
|---|
| provider | any | |
| pasted | any | |
cancelSignInStart(provider: any) → void
Abandon a sign-in in flight.
**Ungated**, for `stop`'s reason: it spends nothing, connects nothing and
closes an attempt the person started. The worst a caller does with it is
end a sign-in nobody finished, which is what the button next to the code
does anyway — and a confirmation in front of "cancel" is a confirmation
in front of the way out.
| arg | type | description |
|---|
| provider | any | |
disconnectStart(provider: any) → void
| arg | type | description |
|---|
| provider | any | |
openSignInStart(provider: any) → void
Open the sign-in page for `provider`.
**No caller names a destination.** The URL is read out of the plugin's
own account state for the provider the caller named, checked to be an
http(s) URL, and only then handed to `openUrl`. Account state that names
no URL errors the job; nothing is opened. The dialog shows the URL's
HOST, so a person sees where they are about to be sent.
| arg | type | description |
|---|
| provider | any | |
conversationDetail(conversationId: string) → void
What a dialog about spending on `conversationId` names.
The AGENT whose plan the turn is paid from, resolved through the plugin's
own lists. Never through the argument's own text: an agent this cannot
name refuses instead, because the call would fail at the plugin anyway
and asking about it first would put the caller's own string on a dialog
about the caller's own spending.
The address is the plugin's grammar (`agent::Address::parse`):
`'<agent>'`, or `'<agent>/<conversation>'`. So the agent is the FIRST
SEGMENT, and reading it from there — rather than from the conversation
list — is what lets the first message to an agent be sent at all. Before
that turn has run there is no conversation to look the agent up in, and a
dialog that could not be built made the composer a dead button on every
agent nobody had used yet.
The conversation list is still consulted, for the one address the
grammar's first segment does not name an agent for: a bare conversation
id. That form reaches the plugin as an agent id and is refused there, but
naming the right agent here costs one cached read and keeps the refusal
the plugin's to give rather than this layer's to guess at.
| arg | type | description |
|---|
| conversationId | string | |
sendStart(conversationId: any, text: any, attachments: any) → void
| arg | type | description |
|---|
| conversationId | any | |
| text | any | |
| attachments | any | |
run( ) → void
**No confirmation.** The person pressed Send in their own
composer, in their own editor, on a plan they connected
themselves. That press IS the consent; a dialog on top of it asks
permission to do the thing they just asked for.
It was asked again after every restart, too, because
`remember = "worldSession"` is the only scope the gate has — so
the ceremony was paid on every engine start, forever, to refuse
nothing the person had not already chosen.
The gate stays where it earns its place: signing in and out, which
move a credential rather than answer a message somebody typed.
agentOf(address: string) → string
Run a slash command — `/model`, `/compact`, `/new`.
**Gated as a spend**, and that is the argument worth making: a slash
command is not a read. `/compact` runs a model turn to summarise a
conversation, and a command that runs a turn spends the person's plan
exactly the way a message does. An ungated `command` would be a way to
spend without the dialog the composer draws — the same spending, under a
name that sounds like a setting.
It asks for the same grant a send does, keyed the same way (action,
plugin, the agent's display name), so a world the person has already
allowed sending in does not ask again for the commands sent in it.
The line is CALLER data and reaches no dialog: the dialog names the
agent, from the plugin's own lists, exactly as `send` does.
The agent half of an address, which is what a model or a level is
actually applied to: the plugin sets them on the SESSION, and a session
is one agent.
| arg | type | description |
|---|
| address | string | |
commandStart(conversationId: any, line: any) → void
| arg | type | description |
|---|
| conversationId | any | |
| line | any | |
run( ) → void
Ungated for the same reason `send` is: the person typed the
command into their own composer. See the note on `sendStart`.
setModelStart(conversationId: any, modelId: any) → void
Read-write but unpriced: picking a model or a thinking level spends
nothing and changes no connection, so no confirmation is asked for. They
are still jobs rather than in-frame answers, because they await the
plugin — and the plugin's refusal of a level a model does not take is
the answer the caller needs to see.
| arg | type | description |
|---|
| conversationId | any | |
| modelId | any | |
setReasoningStart(conversationId: any, level: any) → void
| arg | type | description |
|---|
| conversationId | any | |
| level | any | |
setReactiveStart(agentId: any, reactive: any) → void
Switch an agent's reactivity on or off.
Read-write and unpriced, so no confirmation: it does not spend, and what
it turns off is the plugin starting turns nobody asked for. Still a job,
because the plugin's own answer is what the panel draws.
The agent list is REFILLED before the job settles, not invalidated.
Invalidating only marks the entry: the next read kicks a refresh and
answers the value it was supposed to throw away, so the panel drew the
switch a whole operation behind — measured against the real plugin,
switching off read as still on, and switching back on read as off.
Filling here happens inside the job, which may yield, so the state is
the plugin's confirmed one by the time anything reads it.
| arg | type | description |
|---|
| agentId | any | |
| reactive | any | |
stop(turnId: any) → void
Stop a running turn.
The one verb in the gated family that asks nobody: stopping is always
allowed and always cheap, and a stop that needed a confirmation would be
a stop that arrives after the turn it was meant to end. It answers in
the frame; the outcome arrives as a turn event.
| arg | type | description |
|---|
| turnId | any | |