dotbot.plugin
README.md
dotbot_service
The DotBot bridge, minus its privileges.
src/lua/trusted/dotbot/ is the layer between the DotBot panel (user-VM Luau,
untrusted) and the DotBot plugin (trusted scope, holds a person's ChatGPT /
Claude sign-in). Everything in that layer that is a rule rather than a
capability lives in this module: 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.
local Service = require("@builtin::modules.dotbot_service")
local service = Service.new({
plugin = function() return rawget(_G, "dotbot") end,
await = task.await,
spawn = task.spawn,
consentRequest = function(o) return consent.request(o) end,
consentGranted = function(o) return consent.granted(o) end,
openUrl = function(url) return system.openUrl(url) end,
newJobId = function() return __crypto.uuid_v4() end,
nowMs = function() return os.clock() * 1000 end,
})
Why the split
It 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 can construct an instance
over fakes and drive every rule in it. Being a builtin module, a world script
can construct one too — and wins nothing, because its instance talks to its own
fakes. The one instance that talks to the real consent gate, the real plugin
namespace and the real system.openUrl is built in the trusted VM, out of
globals only the trusted VM holds, and is reachable only as
trusted.call("dotbot.<method>", …).
The shape, and where it comes from
| Constraint | Consequence |
|---|---|
A trusted.call handler must not yield (trusted/exports.luau) | Nothing that awaits is answered in the frame. Awaiting verbs are start/poll jobs; reads are answered from a mirror a spawned task fills, which stays current until a change marks it stale, so an open panel asks the plugin nothing while nothing changes. |
| There is no push from the trusted VM into a user VM | The panel drains a cursor (events(cursor)) rather than registering a callback — so no trusted code ever calls into a user VM, and a world script cannot install itself as the panel's event handler. |
stop is the one verb answered in the frame: stopping is always allowed and
always cheap, and a stop that needed a confirmation would be a stop that lands
after the turn it was meant to end. Its outcome arrives as a turn event.
Signing in
A person's plan is reached by one of two flows, and the plugin says which.
connectStart reads it from the plugin's own account state (M.signInFlow)
and, when a logged-out account names none, asks the plugin — never the caller,
whose choice of flow would be a choice of which endpoint the person is sent to.
| Flow | connect.start answers | Then | Finished by |
|---|---|---|---|
deviceCode (ChatGPT) | { flow = "deviceCode", state = { userCode, verificationUrl, … } } | the person types the code on the provider's page | checkSignIn until the account connects |
pasteCode (Claude) | { flow = "pasteCode", state = { authorizeUrl } } | the person signs in and the browser ends on an address carrying the code, which they paste | completeSignIn(provider, pasted) |
cancelSignIn abandons either.
Several accounts of one provider
Every flow takes an account key: the provider's key (anthropic-claude)
for its first account, the provider and a slot (anthropic-claude~2) for each
further one. addAccountStart(provider) connects a further account beside the
ones already connected: behind the same connect confirmation, it asks the
plugin for the key the new account signs in under (newAccount) and starts
that account's sign-in, answering what connect answers plus the account it
started. renameAccountStart(account, label) keeps a person's name for an
account, as they wrote it (one line, at most 64 characters), and asks nobody. A
dialog that names an account names which one, by the email its provider stated,
once a provider has more than one: the name a person gives an account is set by
an ungated call, so the dialog's words stay the plugin's own.
Only connect asks for a confirmation. The three that continue it do not, for
stop's reason: they carry out a connect the person has already confirmed and
is at that moment completing on the provider's own page, they spend nothing, and
completeSignIn cannot connect anything on its own — the plugin holds a
single-use verifier from the gated connect and answers "no pending login" to
anything else. A second dialog inside one flow is how a person learns to stop
reading the first.
The agents a turn started
A turn starts agents with start_agents, and the plugin reports each on its
feed as a subagent event: { kind = "status", status } on every change,
{ kind = "event", event } for one event of the run's own transcript, and
{ kind = "tool_image", event } for a picture one of its calls returned.
subagents(conversation?) answers the runs held here, oldest first: each the
plugin's snapshot with images, the newest SUBAGENT_IMAGES pictures its
calls returned, and pictures, how many there have been. A status event puts
that run's new record in place; a read answers a frozen copy of the list,
made once per change, whose version moves when a run in it changes.
subagent(id) answers one run's record. subagentUsage(conversation) answers
what a conversation's agents have used, summed from their statuses as they
arrive (runs, the four token counts, creditsCharged), so a run let go
keeps what it added. A picture of a run not held here is let go with it.
The plugin is asked for its list once; status events keep the runs current,
and what a status event said stands over a list the plugin answered before
the event arrived. A list asked again (refresh("subagents")) lets go of the
runs it no longer names, and once more than SUBAGENT_KEEP runs have ended
the earliest to end is let go. A run that reaches one of SUBAGENT_ENDS, and
a run event that ends a turn, mark the run's own conversation's history
stale. stopSubagent(id) asks nobody, for stop's reason, and answers in the
frame.
Turns a usage limit stopped
continuations() is the plugin's list of the turns a usage limit stopped,
each continuing on its own once its window resets, read from the mirror and
asked again on a continuation event or a turn's end. cancelContinue asks
nobody and answers in the frame, for stop's reason; continueNowStart is a
job that starts the turn now, and the provider decides whether the window has
room.
The context gauge
context(conversation) is the plugin's answer for a conversation's context gauge: how much context it holds, its model's window (nil when the model states none) and the auto-compact threshold with the range it may take. Read from the mirror and asked again on a context event (a model call or a compaction measured it, its model changed, or the threshold did), a turn's end, a model, threshold or default account this service set (setModelStart, setAutoCompactStart, which refills it before it settles, setDefaultAccountStart), a sign-in change, or the time its staleAtUnix names, which the plugin states while a window is not known and its catalogue may come to state one.
Which conversation started a workflow run
Built with workflows (the engine's @builtin::modules.workflow), the service
states where a workflow run one of DotBot's tool calls started came from, as
{ host = "dotbot", conversation = "<agent>/<id>", agent? } through
setOrigin, agent naming the agent a turn started when its call did. The
engine keeps it with the run's record.
It follows every tool call of every conversation, and of every agent a turn
started, from its tool_start to its tool_result (or the turn's end, or the
agent's), and listens to the runtime's onChange. A run is a call's when the
call was running as the engine started it and the call's answer names it;
each such run teaches the service the connection DotBot's calls reach the
engine over (the run's started_by.connection). A run no answer names (the
host cuts a long answer) is the calls' when that connection started it and
every call running as it started ran in one conversation for one agent. A run
read back from its record, one started while no call ran, and one already
stated are left as they are. A call with no answer for 15 minutes is taken to
have ended.
close() stops the service listening to the runtime: the engine's service is
closed when the one built after it (the plugin loaded again) takes its place,
and a test closes its own.
Spending
sendStart and commandStart both ask for the spend grant, keyed by action,
plugin and the agent's display name, remembered for the world session. A slash
command is gated because a slash command can run a turn — /compact summarises
a conversation with the model — so it spends the plan exactly as a message does.
What it will not do
- Return a token, a credential id or a header.
M.scrubdrops every credential-named key at every depth, on every value leaving the layer — results and event payloads alike. It is the second line: DotBot's account and event types carry no token by construction. The names it drops are exact, not a substring match ontoken, so the usage counters a person is shown (tokens) and a turn'sturnIdsurvive. - Take a URL.
openSignInStart(provider)reads the verification URL out of the plugin's own account state for the provider named, checks it is an http(s) page, shows a person its host in the dialog, and only then opens it. Account state naming none errors the job. - Answer, dismiss or count a consent dialog. There is nothing here that settles one; the gate is the engine's, and only a person at the keyboard answers it.
- Put a caller's own words on a dialog.
detailis resolved through the plugin's own agent and account lists and the flow errors when it cannot be named rather than falling back to the id it was handed. The two arguments a caller does supply beyond a message — a pasted sign-in code and a command line — are shape-checked (M.pastedCode,M.commandLine) and reach no dialog: the pasted code's flow draws none, and a command's dialog names the agent. - Read a plugin's refusal as an answer. The plugin host raises one where
the call is awaited, so
callreports it as a failure and a job never settlesdoneon a sign-in that did not happen.
Constants
| Name | Meaning |
|---|---|
RING_CAPACITY | 512 — events held. A cursor older than the tail is answered dropped = true. |
JOB_LIMIT | 8 — jobs live at once, across every caller. A ninth start is { ok = false, reason = "busy" }. |
JOB_TTL_MS | 600000 — a job nobody polls is dropped after ten minutes. |
RETRY_MS | 2000: how long a read the plugin could not answer waits before it is asked again. A read that has an answer stays current until a change marks it stale: an account, usage, continuation, context or turn-ending turn event, the time an answer's staleAtUnix names (a usage answer, and the agents and default accounts built on the same reading, LAPSES_WITH), a turn in a conversation the list does not hold, a job this service ran, or refresh(name, arg?) (with arg, the one read of name it names). A sign-in, a sign-out and an account event mark the accounts, the agents, the models and the usage. A new conversation list marks the history of each conversation whose message count or last update it shows moved. |
MIRRORED | The reads the mirror holds: agents, accounts, models, usage, conversations, history, subagents, defaultAccounts, continuations, context. refresh(name) takes one of them. The mirror also holds toolImage(conversation, image, size), where the plugin put one picture a tool call returned for the UI to draw: asked once per picture and size, and kept, because the copy stays where it was put. |
TURN_ENDS | The turn event types that end a turn (end, done, error), after which the conversation list, the history, usage, accounts, agents, continuations and context gauges are asked again. |
SUBAGENT_ENDS | The states a run ends in: done, failed, cancelled. |
SUBAGENT_IMAGES | 8: the pictures kept with each run, newest last. |
SUBAGENT_KEEP | 64: the ended runs held, the plugin's own number. |
PLUGIN_NAME | "dotbot" — the name the consent gate resolves the dialog's display name from. |
DEVICE_CODE / PASTE_CODE | "deviceCode" / "pasteCode" — the two sign-in flows, as connect.start names them back. |
Tested by src/lua/lib/tests/suites/dotbot_exports.testSuite.