Mention @boxliteai in any public GitHub issue or pull request, or in your team's Slack, and it answers in the thread. For a repo's maintainers it can also open a draft PR. There's nothing to install on GitHub: it's a regular GitHub account. Each request runs Codex CLI in that thread's own BoxLite microVM, with a full shell and network, so it can run the code before it answers. Slack gets all of that too, draft PRs and the admin commands included. There it also gets the files attached to a message, and it can read your team's Linear, Notion and Google Workspace — in Slack as the person asking, so only what they can see — and create or edit Workspace documents, calendars and events, prepare Gmail drafts, and comment and file things in Linear and Notion.
@boxliteai why does `npm test` fail on this PR? (GitHub)
@boxliteai add retries to the client in src/http.ts and open a PR (GitHub)
@boxliteai what broke? (ci.log attached) (Slack)
@boxliteai what's left on LIN-231, and does the spec in Notion agree? (Slack)
- One box and session per GitHub thread or Slack requester within a thread. Your follow-ups resume your session; another channel member gets their own box, files and context. A PR's checkout follows its latest head; a Slack channel follow-up sees only the requester's messages since their last turn.
- Codex may do anything in its box: no sandbox, no approval prompts, no hook reviews,
sudo, network, live web search. The microVM is the boundary, and nothing worth stealing is ever inside it. - Every box runs agent-tooling, BoxLite's shared
Codex plugin with its skills, auditors and hooks. It's refreshed to the tip of
mainwhen it's over 10 minutes old. The Codex runs its hooks start go through the controller too. - Only the controller holds credentials. Codex runs on a stand-in login whose token works only on the controller's proxy, and only until the turn ends.
- GitHub and Slack never mix. See below.
- PRs only for people who may ask. Codex commits in its box; the controller checks the change and opens the PR. Everyone else gets the change as a diff in the reply.
- Slack events use Socket Mode. The controller dials out to Slack; browser account linking uses the controller's existing public HTTPS URL.
- The team's tools go through the controller too. Linear, Notion and Google Workspace reach Codex as MCP servers on the controller, behind the same job token. The controller checks every call against the tools you allow and swaps in the right login — in Slack the asker's own. See Linear, Notion and Google Workspace.
The controller stays attached to the exec for the whole turn, because BoxLite reaps an exec nobody
is attached to. It stops the box as soon as the turn ends, then posts the reply. It acks every
Slack event the moment it arrives. In Slack, Codex's Markdown goes out in Slack's markdown block,
and a long answer is split into a few messages, never in the middle of a code block.
Live state (the checkout or working directory, and CODEX_HOME) stays on the box's own disk, since
the S3-backed volume has no rename or append. After every turn CODEX_HOME is sealed with
AES-256-GCM under a key derived for that session, and written to its directory on its
side's volume: sessions/<owner>/<repo>/<n>/ for GitHub, or
sessions/<team>/<channel>/<thread ts>/<requester>/ for Slack channels (DMs omit <requester>/). Every box
mounts its side's whole volume, but only that session's box gets the key. A session's box is deleted
after 15 quiet minutes (BOX_TTL_MIN), and the next mention restores into a new one. The
conversation carries over; files from earlier turns don't, and Codex is told so.
A GitHub thread's box runs a public thread's code at anyone's request. A Slack thread holds your team's conversations. So the two sides share nothing a box can reach:
| GitHub thread | Slack thread | |
|---|---|---|
| Who can ask | anyone, in public repos | members of your workspace |
| Its box | botlite-gh-acme-app-7-<hash> |
botlite-slack-dm-alice-0922-<hash>, botlite-slack-backend-bob-0922-<hash> |
| Its volume | botlite-context (VOLUME) |
botlite-slack-context (SLACK_VOLUME) |
| Its context key comes from | CONTEXT_SECRET |
SLACK_CONTEXT_SECRET |
| The team's tools | none — no shared bot login | each person, on the login they linked themselves (in a DM) |
| Who may ask for a PR | the bot's admins, the repo's maintainers, people an admin added | every member, into any public repo (SLACK_PR_REPOS) |
| Who runs the commands | the bot's admins (BOT_ADMINS) |
the workspace's owners and admins |
A GitHub box never mounts the volume where Slack's threads are kept. Nor the other way round: BoxLite can't mount a volume read-only yet, and a Slack box that could write to GitHub's volume could leave something there for every GitHub box to read. A thread only ever runs as its own kind, and a config that would give both sides one volume or one secret keeps Slack off. A turn's job token opens only the tools that turn was given, so a stranger's turn on GitHub can't reach Linear with its own token. A box's name says which thread it runs: the repo and number, or where the Slack thread is, who started it and when. What makes it that thread's alone is the hash of the thread's whole key at its end, so nobody can name a repo to land in another thread's box. The deploy's public log shows only how the controller started, never a line about a thread.
- Who may ask: the bot's admins (
BOT_ADMINS) anywhere; a repo's maintainers (owner, member, collaborator) there; and anyone an admin added to a repo with/add. - What's checked, on GitHub's diff of the exact commit pushed: that it's a change on top of the
base, and nothing else. A human merges every PR, so nothing is refused for what it touches or
how big it is. Called out at the top of the PR: dependency and lockfile changes, and changes to
.github/workflows,.github/actions,CODEOWNERS,.gitmodulesorFUNDING.yml. CI runs a PR's own workflows before anyone has reviewed it, so require approval for outside contributors' runs in a repo with self-hosted runners. - Its fork runs no Actions. Before a turn's first push, the controller turns Actions off on the bot's fork: a push there would run the box's code with a token that can write the fork. If it can't, nothing is pushed. The turn's push token may then carry workflow files, if the push App may write them.
- What's published: one commit by the bot on
botlite/<owner>/<repo>/<n>in its fork, as a draft PR into the default branch. For someone else's PR, the draft PR goes into that PR's branch; on a PR the bot opened, the commit goes straight onto its branch. A follow-up adds a commit, and a branch that moved meanwhile is never overwritten.
| Command | Who | Does |
|---|---|---|
@boxliteai /help (or just @boxliteai help) |
anyone | the commands, and whether you can ask for PRs here |
@boxliteai /add @user · /remove @user |
admins | who else can ask for PRs in this repo |
@boxliteai /list |
admins | who has been added here |
@boxliteai /pause · /resume |
admins | stop or restart all PR writing |
@boxliteai /model [model] [effort] |
admins | show, or set, the model and reasoning effort every turn runs on, GitHub's and Slack's, e.g. /model gpt-6-astra xhigh; /model default undoes it |
@boxliteai /deploy |
admins | put what's merged on main live now (see below) |
These are GitHub comments. In Slack, the workspace's owners and admins run the same ones on the
same state: @boxliteai /model …, /deploy (it reports back in that Slack thread), /pause and
/resume, so one /pause stops PR writing on both. /add, /remove and /list stay on GitHub:
in Slack every member may ask for PRs.
Every Slack member can send @boxliteai /link linear, /link notion or /link google (include
the mention in channels). Open the private, ten-minute link and approve your account. The browser confirms
when connected; your next request uses your account. A new command replaces an unfinished
link, and a controller restart expires unfinished links. Linked accounts survive restarts.
/link drive, /link docs, /link sheets, /link slides, /link calendar, /link calendars, /link gmail and
/link google workspace all connect the same Google account for the enabled Workspace tools.
In channels, answers are private to the person who asked, including tool results and failure
notices. Alice and Bob can ask in the same thread: each uses their own linked accounts and context.
One person's follow-ups run in order; different people can run concurrently within MAX_CONCURRENT.
Only that person's earlier messages are imported into their channel session. Old shared channel
sessions are not resumed. Private channel replies disappear after a Slack reload; use a DM for
persistent replies. The bot never falls back to a public answer if private delivery fails.
/model takes a model only if ChatGPT's Codex backend offers it (and that effort) to the bot's
pinned Codex, since a bad one would fail every turn.
The controller answers these itself; Codex never sees them. Admin commands count only in a new, never-edited comment, because anyone with write access to a repo can edit other people's comments there. People are kept by GitHub id, since a login can change hands.
Merging to main is deploying. Once the test workflow passes on a push to main, the
deploy workflow restarts the controller onto it: running turns finish first, the boot loop pulls
main, and the new build starts with the bot's logins and state. /deploy does the same from a
thread, and says there how it went. A bot PR that touches its own trust boundary (access,
publishing, the push route, credentials, the runner, deploy) opens with a warning.
| If the new build… | then |
|---|---|
| doesn't parse, link or start, or breaks the launcher | the pull gate, a hook no pull can change, walks back to the newest pulled commit that passes (or the build it had), which starts and says why in the thread |
| passes the gate, but crashes 3× in its first 10 minutes, or at the end of them isn't polling GitHub or (once set up) connected to Slack | the launcher rolls back to the last good build, and the thread is told |
| runs, but can't run a turn (a broken runner, say) | a new build runs one small turn of its own a minute into its trial, and once more two minutes later if that fails; two failures roll it back |
| hangs, stuck or with its event loop blocked | a watchdog thread kills it after 10 minutes without progress and the boot loop starts it again; a build still on trial is rolled back |
| can't reach GitHub or Slack | /healthz answers 503; the health workflow opens an issue, and closes it once it's back |
| misbehaves some other way | the deploy workflow's rollback runs an earlier commit of main |
A rollback keeps what a newer build wrote to the state: fields an older build doesn't know are kept, not dropped.
Mention @boxliteai in a channel it's in, or send it a direct message. It answers in the thread,
and a follow-up there (a mention again, in a channel) continues the same session. Attach logs,
screenshots or code to the message and the box gets them too: up to 5 MB a file and 8 MB a message.
They reach that thread's box only, on the exec's stdin, and never go on a volume.
A PR from Slack. Ask for a change as a PR and it opens a draft PR from the bot's fork, into any
public repo (SLACK_PR_REPOS in src/policy.mjs can narrow that, to boxlite-ai/* say). A Slack thread
belongs to no repo, so the PR is asked for at the end of the turn:
- Codex clones the repo in its working directory, commits the change there, and leaves
pr.json(which repo, which clone). - The runner asks the controller for the push (
/pr, with the turn's job token). The controller checks: PR writing on, the repo allowed, the commits built on its default branch, one PR per turn. Then the runner pushes to the one staging branch the controller names. - After the turn, it's GitHub's path: the same checks on GitHub's own diff, one squashed commit by the bot, and a draft PR, whose link goes under the answer.
The PR is public, and says only that it was asked for in Slack. No names, links or Slack ids go into it, nor into its branch's name. Codex is told its commit messages become the PR's title and description.
Who can use it is mayUseSlack in src/policy.mjs. It runs before any quota is spent or box
started, and a refused person gets an explanation only they can see. As shipped it's members
only:
| Who | How you can tell | As shipped |
|---|---|---|
| Full members of your workspace | user.team_id === home.teamId, not a guest |
yes |
| Members of a sibling workspace on Enterprise Grid | enterprise_user.enterprise_id === home.enterpriseId |
yes |
| Guests (often contractors) | is_restricted; single-channel: is_ultra_restricted |
no |
| Other organizations, in Slack Connect channels | team_id isn't yours, or is_stranger |
no |
| Any request in a Slack Connect channel | where.extShared |
no: even a member's answer lands in front of the other org |
| Deactivated accounts, bots | deleted, is_bot |
never (the tests insist) |
Each person links their own Linear, Notion and Google Workspace, and a turn uses the requester's own login — so the bot reads only what that person can already see, and no one's private data reaches anyone else through it. Someone who hasn't linked a service simply can't use it, and the bot tells them how. There is no shared bot login for anyone to borrow, and no team tools on GitHub (a public thread, run at anyone's request). In Slack, DMs use that person's account; channel requests use separate sessions per requester and private replies. Linking does not give other channel members access to your connection or results. Link only accounts that belong to you.
How a tool call flows, end to end. The box holds no tool credential. Its Codex reaches each
service as an MCP server on the controller (/mcp/<service>), carrying only the turn's job token;
the controller checks the call, swaps in the asker's own login, and forwards it to the service's
official MCP server. Creating a separate calendar uses the same broker checks and requester token,
with a small MCP adapter for Google's Calendar REST API; Google's Calendar MCP covers event tools.
sequenceDiagram
participant A as Asker (private Slack reply)
participant B as Session box (Codex)
participant C as Controller (broker)
participant S as Official MCP server
A->>B: a request, in one thread
Note over B: no credentials —<br/>only this turn's job token
B->>C: POST /mcp/linear (Bearer job token)<br/>tools/call save_issue
Note over C: verify the token · was this turn<br/>given linear? · is the login in place? ·<br/>is save_issue on the policy list? ·<br/>under the 10-change / 60-call budget?
C->>S: the same call + the asker's own login
S-->>C: result
C-->>B: result (MCP headers only, no vendor cookies)
Note over C: logs who · thread · tool,<br/>counts the change
B-->>A: the answer, ending<br/>"changed: Linear save_issue"
Any failed check ends the call there — a service the turn wasn't given, a tool not in TOOLS, a
spent budget — so a public GitHub thread can't reach a tool its turn never got, and Codex can't
call one you didn't list, however it's asked. One real Slack request ("file a Linear issue and
create a Notion page"), as the controller logged it:
linear list_teams (read: find a team)
notion notion-fetch (read)
linear save_issue (a change) → the issue
notion notion-create-pages (a change) → the page
notion notion-get-users · linear get_user · notion notion-fetch (reads: the links)
answered Dorian, changed: Linear save_issue · Notion notion-create-pages
| Linear | Notion | Google Workspace | |
|---|---|---|---|
| What it sees | what that member sees | the pages shared with it | files and calendars shared with it |
| A Slack person links their own | @boxliteai /link linear |
@boxliteai /link notion |
@boxliteai /link google |
| Lasts | until revoked; OAuth tokens refresh automatically | 180 days, then link again (ctl status shows the date) |
until revoked, with an Internal consent screen |
Linking a Slack person binds their own token, keyed to their Slack id — the U… in the log's
request from … (U…). The person completes any browser consent themselves, so it's their access
being bound, and the operator never sees a Notion/Google token. node deploy/ctl.mjs links lists
who's bound; node deploy/ctl.mjs unlink <service> <id> removes one. When someone asks for a tool
they haven't linked, the bot tells them to link it first and does nothing else with it.
- Linear: browser linking uses Linear MCP's OAuth registration and PKCE, bound to the requester
and browser. Tokens stay on the controller; its tool allowlist still limits changes. No OAuth app
setup is needed. Existing personal API keys remain supported through
LINEAR_API_KEY=… node deploy/ctl.mjs link linear <their Slack id>. - Notion: the private Slack link uses Notion MCP's OAuth registration and PKCE. The person
approves it as themselves; nothing to set up first. Terminal linking remains available through
node deploy/ctl.mjs link notion <their Slack id>. - Google: the Workspace MCP servers are in a
Developer Preview. Join it,
then in a Cloud project enable the Drive, Docs, Sheets, Slides, Calendar and Gmail APIs and their MCP
APIs, and set the OAuth consent screen to Internal. For Slack linking, create a Web
application OAuth client with the exact authorized redirect URI
https://<controller-public-host>/link/google/callback. Hand it to the controller once:GOOGLE_WEB_CLIENT_ID=… GOOGLE_WEB_CLIENT_SECRET=… node deploy/ctl.mjs google-client. The client stays on the controller, takes effect without restarting, and grants no user access by itself: each person chooses their account and approves consent. Missing setup gives a private administrator-setup message. Scopes come from the tool policy; link again after allowing new tools. Terminal linking still uses a separate Desktop app client throughGOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=… node deploy/ctl.mjs link google <their Slack id>.
Terminal logins run on your machine and hand tokens to the controller. Slack's browser linking exchanges the authorization code on the controller; neither Slack nor a session box gets tokens.
What the bot may do is TOOLS in src/policy.mjs: reads are listed, and every other call is
refused before it reaches the service, whatever Codex asks. Changes are the ones in WRITES: as
shipped, comments and issues in Linear (save_issue edits issues too) and comments and new pages in
Notion, Workspace file creation and editing, calendar creation and event invitations, and Gmail
drafts. File edits can replace existing content, and event invitations notify their attendees. A change is
made as the person asking, on their own login, in their private session with the bot. In channels,
only that person's messages enter the session. The enabled Google changes are:
| Service | Change tools | Notes |
|---|---|---|
| Drive | create_file, copy_file |
Create Docs, Sheets and Slides with their Google MIME type |
| Docs · Sheets | update_doc · update_values, update_formulas, update_spreadsheet, insert_dimension |
Edit files the requester can edit |
| Slides | update_presentation |
Edits may replace or remove presentation content |
| Calendar | create_event, update_event |
Invite the requested attendee addresses; use notificationLevel: ALL to notify them |
| Calendars | create_calendar |
Create a separate calendar owned by the requester; does not share it |
| Gmail | create_draft |
Prepare mail for the requester to review and send themselves |
For example: “Create a Planning calendar, schedule a kickoff with alice@polygala.ai, and draft
an email about it.” The calendar id from calendars.create_calendar becomes the event's
calendarId; gmail.create_draft leaves the email in Drafts. For “everyone,” provide a verified
group address or attendee list: the bot has no Workspace directory access to enumerate members.
Google's gmail.compose scope also permits sending, but the controller exposes only draft
creation and refuses send tools. Calendar creation uses calendar.app.created; event writes use
calendar.events. The controller exposes no calendar deletion, calendar sharing or mail sending
tools. After tools/scopes change, each member must run /link google again to approve access.
Each request may make up to 10 changes in up to 60 tool calls. Under its answer, the bot lists the changes it made, as the controller recorded them. The controller log records every tool call: who asked, in which thread, which tool.
What stays risky: what Codex reads through these tools sits in a box with open internet access. The controller keeps the logins out of the box and limits what Codex can do, but a message that talks Codex into it could still send what it read somewhere else. On GitHub, that includes the public reply. Keep the bot's accounts narrow, and add changes one at a time.
| Credential | Controller | Session box | Volume |
|---|---|---|---|
| Bot's GitHub token | holds: polls, reacts, replies, forks, opens PRs | never (clones anonymously) | never |
| Push App key | holds: one token per write turn, for that fork only | never (pushes via the controller) | never |
Slack bot token (xoxb-) |
holds: reads threads, people, files; reacts, replies | never | never |
Slack app token (xapp-) |
holds: opens Socket Mode connections | never | never |
| Linear API key | holds: Linear's MCP server | never | never |
| Notion and Google logins | hold and refresh them | never | never |
| BoxLite API key | placeholder (a BoxLite secret) | never | never |
| ChatGPT login | holds and refreshes | never (a stand-in login) | never |
| Job token | issues, revokes | its own turn only: model calls, a write turn's one push (a Slack turn asks for it at /pr), the tools its turn was given |
never |
| Thread context key | derives, from its side's secret | its own thread only | never (sealed bytes only) |
| Webhook secret | holds | never | never |
The proxy forwards only Codex's two model endpoints, a write turn's push to its one staging branch and the tool calls your policy allows the turn. It 404s everything else, and caps requests and tool calls per turn. Text from GitHub and Slack goes into the prompt fenced as untrusted context, never as instructions.
Every key the controller holds is also in the repo's production environment (see
From GitHub), so the controller box isn't the only copy. The ChatGPT, Notion and
Google logins are the exception: their refresh tokens change as they're used, so a copy anywhere
else would go stale.
| File | Runs in | Does |
|---|---|---|
src/main.mjs |
controller | the launcher: rolls back a build that won't go live, then starts the controller |
src/controller.mjs |
controller | wiring: credentials, poll loop, quotas, replies, drain on restart |
src/deploy.mjs |
controller | /deploy: what's merged since the running build, and how the deploy went |
src/mentions.mjs · webhook.mjs |
controller | mentions from polled notifications or App pushes |
src/slack-channel.mjs |
controller | Slack: who may ask, each message to a turn, the answer back |
src/slack-socket.mjs · slack-events.mjs |
controller | Socket Mode events, acked at once; Slack events → requests, markup → text, files |
src/slack.mjs · slack-reply.mjs |
controller | Slack Web API as the bot: 👀, replies, file downloads |
src/policy.mjs |
controller | your policy: who may use the bot in Slack, which tools it may use |
src/tools.mjs · oauth.mjs |
controller | the tool broker at /mcp/<service>; the bot's logins, kept fresh |
src/jobs.mjs · state.mjs |
controller | one turn per session, a few at once; seen requests, sessions, quotas |
src/session.mjs |
controller | one turn: its side, start or create the box, exec the runner attached, stop it |
src/proxy.mjs · chatgpt.mjs |
controller | the public port: model endpoints only, job token → real login |
src/codex.mjs |
controller | codex exec / resume arguments, prompts, event parsing |
src/access.mjs |
controller | who may publish on GitHub; /help and the admin commands |
src/gitpush.mjs · githubapp.mjs |
controller | a write turn's one push: job token in, App token out, one ref |
src/prgrant.mjs |
controller | /pr: a Slack turn asks for its PR's push when its work is done |
src/publish.mjs |
controller | plan a write turn; check the pushed commit, squash it, draft PR |
src/boxlite.mjs |
controller | BoxLite REST, exec attach over WebSocket |
src/github.mjs · reply.mjs |
controller | GitHub REST as the bot: 👀 and replies |
box/session.mjs |
session box | restore → stand-in login → checkout or files → Codex → push commits → seal |
deploy/deploy.sh · ctl.mjs |
your terminal, GitHub | create the controller; operate it, hand over keys, roll back |
deploy/login.mjs |
your terminal | the one-time Notion and Google consent, handed to the controller |
deploy/post-merge.sh |
controller | the pull gate: a pull runs only up to its newest commit that passes |
.github/workflows/ |
GitHub Actions | test every PR; deploy main when its tests pass; health |
slack/manifest.json |
Slack | the app: bot scopes, events, Socket Mode on |
export BOXLITE_API_KEY=blk_live_…
BOT_ADMINS=you bash deploy/deploy.sh # creates the public botlite-controller box
GITHUB_TOKEN=ghp_… node deploy/ctl.mjs github-token # the bot account's classic PAT
GITHUB_APP_ID=… GITHUB_APP_KEY=app.pem node deploy/ctl.mjs github-app # optional: PR writing
SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… node deploy/ctl.mjs slack-tokens # optional: Slack
node deploy/ctl.mjs status # what it's waiting for, e.g. the ChatGPT login- BoxLite key: it must be able to create boxes. If it can't create volumes, create
botlite-contextandbotlite-slack-contextin the dashboard first. - GitHub token: a classic PAT on the bot's own account with
notifications+repo+workflow(the notifications API rejects fine-grained tokens).workflowlets the bot's forks catch up with an upstream that changed a workflow; without it, PRs from such a fork fail.repolets the bot turn Actions off on its forks, which PR writing needs (withpublic_repoalone it only answers). It also reaches private repos, so keep the bot's account out of them: the deploy warns if it can see any. Neverdelete_repo. The bot is whoever the token belongs to. - Push App (PR writing): a GitHub App of its own, separate from the webhook App. Give it
Repository permissions → Contents: Read and write, and Workflows: Read and write if its PRs
may change CI, with no webhook. Install it
on the bot's account for all repositories, so new forks are covered, generate a private key,
and hand both over with
ctl github-app. Without it the bot only answers. The controller mints one token per write turn for that turn's fork, and the box never sees it. - Slack app: at api.slack.com/apps, Create New App → From a
manifest, pick the workspace and paste
slack/manifest.json. Under Basic Information → App-Level Tokens, generate one with theconnections:writescope: that's thexapp-…token. Install App → Install to Workspace gives the Bot User OAuth Token,xoxb-…. Optionally, uploadslack/icon.pngas the app icon. Invite the bot where people should use it:/invite @boxliteai. No restart needed: the controller connects once the tokens are in. The manifest leaves outchannels:readandgroups:read; add them if you want channel threads' boxes named after their channel, not only after who started them. - ChatGPT login: the controller runs
codex login --device-authin its box, andstatusshows the link and code to approve with the bot's ChatGPT account. Use an account only the bot uses, and never copy another controller'sauth.json: each refresh rotates the token, so two holders of one login knock each other out. CONTEXT_SECRETseals GitHub threads' contexts and signs job tokens, andSLACK_CONTEXT_SECRETseals Slack threads'. Each is generated on first start. Pass the same values again to keep every thread's context:CONTEXT_SECRET=… bash deploy/deploy.sh, andSLACK_CONTEXT_SECRET=… node deploy/ctl.mjs slack-tokenswith the Slack tokens.
Day to day: node deploy/ctl.mjs status | logs [n] | webhook | restart | rollback <commit> | admins <logins>.
restart pulls the tracked branch and lets running turns finish first (Slack requests that arrive
meanwhile are kept for the next start); rollback runs an earlier commit of it until the next
restart; admins replaces BOT_ADMINS and restarts, with no redeploy. Add --wait to any of the
three to wait until the next start is live and see which build it is.
Keep the keys in the repo's production environment instead of on a laptop, with only main
allowed to deploy:
- secrets
BOXLITE_API_KEY,BOT_GITHUB_TOKEN,PUSH_APP_PRIVATE_KEY,SLACK_BOT_TOKEN,SLACK_APP_TOKEN,SLACK_CONTEXT_SECRETand, if the bot uses Linear,LINEAR_API_KEY; CONTEXT_SECRETandWEBHOOK_SECRET, for recreating the controller box withdeploy.sh;- variables
PUSH_APP_IDandBOT_ADMINS.
Then the deploy workflow operates the controller. Every push to main whose tests pass goes
live by itself; by hand it does:
| Action | Does |
|---|---|
restart |
pull main and restart onto it |
handover |
give the controller its tokens and keys from the environment, e.g. after a rotation, and reinstall the pull gate |
rollback |
run an earlier commit of main until the next deploy |
Each run waits until that build is live, and fails with how its start went if it isn't. That log is
public, so it shows only the lines about starting. health checks /healthz every 15 minutes
once the CONTROLLER_URL variable is set. test runs npm test on every PR.
Settings
The controller reads these from its environment; deploy.sh passes VOLUME, CODEX_MODEL,
CODEX_EFFORT, BOTLITE_REF, BOT_ADMINS, CONTEXT_SECRET and WEBHOOK_SECRET through.
| Env | Default | |
|---|---|---|
BOT_ADMINS |
none | GitHub logins, comma-separated, who may ask for PRs anywhere, use the team's tools on GitHub, and run the admin commands (ctl admins replaces it) |
VOLUME / SLACK_VOLUME |
botlite-context / botlite-slack-context |
GitHub threads' and Slack threads' context volumes, never the same one |
CODEX_MODEL |
Codex's default | model for every turn (also pinned by the proxy), until an admin's /model |
CODEX_EFFORT |
the model's default | reasoning effort for every turn (low … xhigh, max, ultra, as the model allows), until /model |
BOTLITE_REF |
main |
the branch the controller runs |
SESSION_IMAGE / SESSION_CPUS / SESSION_MEMORY_MIB |
node / 2 / 4096 |
session boxes |
MAX_CONCURRENT |
3 |
turns running at once |
DAILY_LIMIT_PER_USER |
20 |
requests per GitHub user per UTC day; the bot's admins have no limit |
SLACK_DAILY_LIMIT |
0 (no limit) |
requests per Slack user per UTC day |
JOB_TIMEOUT_MIN |
20 |
wall-clock limit of one turn |
BOX_TTL_MIN |
15 |
a thread's box is deleted after this many minutes without a turn (BoxLite's auto_delete; there's no auto-stop, since the controller stops each box after its turn) |
MAX_FILE_MB / MAX_FILES_MB |
5 / 8 |
the largest Slack attachment the box gets / all of a message's together |
HANG_MIN |
10 |
the watchdog kills a controller that makes no progress this long |
PORT / PUBLIC_URL |
8788 / looked up |
the proxy's port and public origin |
BOXLITE_URL |
https://api.boxlite.ai |
BoxLite API |
Polling takes about 30–85 s from mention to pickup. Repos that install the BoxLite Agent GitHub
App get each mention pushed to the controller's POST /webhook instead. node deploy/ctl.mjs webhook prints the URL and secret for the App. Subscribe it to Issues, Issue comment, Pull
request and Pull request review comment, with read-only Issues and Pull requests
permissions. A mention that arrives both ways is handled once.
Install the shared agent-tooling once per clone or worktree (requires Git, Bash, jq, and Perl):
./.agent-tooling/install.shThis configures local Git hooks and refreshes the shared guidance in AGENTS.md.
The repository profile in .agent-tooling/profile.json follows tooling main
and declares npm test. Host plugins require installation in the host.
npm test # offline: no network, BoxLite, Slack or model
BOTLITE_E2E=1 npm test # + a real Codex turn and resume through the proxy (needs codex 0.155.1)Run ./.agent-tooling/install.sh in each worktree after a tooling upgrade to
refresh hooks and managed guidance. Keep committed bootstrap scripts synchronized
with the adopted release templates.
The 0.1.26 Claude bootstrap checks the adopted manifest version and reinstalls a
stale project plugin when an update leaves it unchanged, preserving project
settings. Run /reload-plugins afterward to activate the refreshed plugin.
- Public repos only, and PRs only on request. It opens draft PRs from its own fork, only for the people above, and never pushes to anyone else's branch.
- Forks that fall behind. Before a push the controller syncs the fork with upstream. If
upstream changed a workflow file since the last sync, GitHub refuses that sync unless the bot's
PAT has the
workflowscope, and then refuses the push, which would bring that change in. The reply says which workflow and what the operator can do. - One Slack workspace, answers only. It's an internal app on Socket Mode; offering it to other workspaces would take OAuth and the Events API. It sees only its thread, and posts only there.
- The box has the open internet. A Slack thread's text, private channels included, and whatever the tools read go into a machine that can send them anywhere if a message talks Codex into it. Keep the bot out of channels whose contents must not leave.
- A personal ChatGPT plan serves everyone. OpenAI's terms may not allow a consumer login to be used this way; an API key is the sanctioned route for a public service.
- Codex's private backend. The model path depends on ChatGPT's Codex backend and Codex's login
format as of 0.155.1, which is pinned.
BOTLITE_E2E=1 npm testchecks it on upgrade. The backend offers each model only from some Codex version on (gpt-6-astra: 0.153.0). - Tool names come from the services. The allowed tools are listed by name. A service that renames a tool turns it off until the list is updated; the controller log names every refused call.
- Region. The controller must reach
chatgpt.comandauth.openai.comfrom a country OpenAI supports.