Skip to content

Repository files navigation

OpenBot Logo

OpenBot

Self-hosted multi-bot collaboration platform for engineering teams.

AGPL-3.0 License Docker Compose SvelteKit Fastify PostgreSQL


OpenBot is an AGPL-licensed, self-hosted multi-bot collaboration system. The current implementation includes local and optional OIDC authentication, workspace membership, groups, scoped API tokens, and persistent Bot identities: a SvelteKit web app, a Fastify API, PostgreSQL migrations, and a Docker Compose development stack. Personal OpenAI Chat Completions, Responses, and Anthropic Messages-compatible model connections support credential-safe settings and live text/action compatibility probes.

Workspace model connections support shared use without sharing credentials. Capability settings record verified evidence, explicit overrides, and compatible fallback chains.

The approved implementation backlog lives in .scratch/openbot/issues/.

Quick start

Requirements: Docker with the Compose plugin.

cp .env.example .env
docker compose up --build

The example explicitly enables placeholder passwords for an isolated local stack. Before any non-local deployment, set unique POSTGRES_PASSWORD and OPENBOT_DATABASE_PASSWORD values, replace OPENBOT_SETUP_TOKEN with a high-entropy secret of at least 32 bytes, update DATABASE_URL, and set OPENBOT_ALLOW_INSECURE_LOCAL_PASSWORD=false; PostgreSQL otherwise refuses the example passwords.

Published ports bind to loopback by default. If the service must accept remote connections, set OPENBOT_BIND_ADDRESS deliberately, use a high-entropy OPENBOT_SETUP_TOKEN, terminate TLS, and set WEB_ORIGIN to the exact external HTTPS origin before exposing either port.

When every service is healthy:

The first setup asks for the OPENBOT_SETUP_TOKEN from .env, then atomically creates the instance administrator, default workspace, owner membership, and a persistent session. Later setup attempts are rejected. The setup secret is sent only for that claim request and is never written to the application database or audit history. For any non-loopback deployment, set WEB_ORIGIN to the exact external HTTPS origin; the API and SvelteKit server use that same value for Origin checks and Secure cookies.

The Compose migration service alone receives the PostgreSQL owner credentials. The API and worker connect as the fixed, non-superuser openbot_runtime role using OPENBOT_DATABASE_PASSWORD. That role can perform the application queries required by authentication, but cannot change database schema, remove the audit trigger, or update, delete, or truncate audit_events.

Stop the stack with docker compose down. Add --volumes only when you intentionally want to remove local PostgreSQL data, attachments, avatars and export archives.

OpenBot sends no telemetry, analytics or update checks and has no setting that enables them; an idle instance makes no outbound request. Named volumes mean restarting every container preserves users, workspaces, task state, and attachments. A model endpoint on a private network works without publishing a model service: list its host in OPENBOT_PROVIDER_ALLOWED_HOSTS and its network in OPENBOT_PROVIDER_PRIVATE_CIDRS. See Single-host deployment for the service layout, network exposure and persistence.

Upgrades are explicit: back up, build, docker compose run --rm migrate, then up. A database whose schema this build cannot serve — older, newer, or from another lineage — keeps the API and worker from becoming ready and GET /api/v1/status says what to do. See Upgrading for the supported versions, the backup and the recovery path.

docker compose run --rm backup backup /backups/<name>.tar writes one checksummed archive of the database and the object volume at a single boundary (job acquisition paused, running work drained), and restore loads it only into an empty instance of the same schema. See Backup and restore.

Cutting a release follows MVP release acceptance: a green Verify run on the shipped commit, node infra/release-manifest.mjs for the reproducible release document, and the manual checks on the candidate.

Optional OIDC

OIDC stays disabled when its environment values are empty. To enable a provider, register a confidential Authorization Code client using client_secret_post with the exact redirect URI WEB_ORIGIN/auth/oidc/callback, then set OIDC_ISSUER_URL, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET and restart the API. The provider must support S256 PKCE, OIDC discovery, and signed ID tokens with a JWKS endpoint. Issuer and backend provider endpoints must use HTTPS. The loopback HTTP exception is limited to the automated test environment.

Existing users sign in locally and open Security settings to explicitly link their provider identity. Later OIDC sign-in matches the exact issuer and subject; an email match never merges accounts. New users can join only through a valid workspace invitation, with a verified provider email matching the invitation. OIDC-only accounts cannot unlink their final credential. Security settings remain available if a user has no workspace memberships.

The client uses Authorization Code, PKCE, state, nonce, signature verification, and a ten-minute single-use transaction tied to an HttpOnly browser cookie. Invitation acceptance commits the account, external identity, membership, session, and audits together. Callback errors return to clean application URLs without authorization codes or state in error messages.

Workspace API tokens

Select API tokens in a workspace to create a named token with fixed scopes and an expiration (default 30 days, maximum 365 days). Copy the secret from the creation result; subsequent loads show only metadata. Revoke a token from the same page. You can manage only your own tokens.

The public identity endpoint is GET /v1/me, authenticated with Authorization: Bearer <your-token> and the me:read scope. It returns the creator, the bound workspace and current role, and token ID/scopes. Browser session identity remains /api/v1/me. Tokens in URL query parameters are rejected. Invalid, expired, revoked, or orphaned tokens return 401; a valid token without the required scope returns 403.

The fixed scope catalogue is me:read, bots:read, bots:write, groups:read, groups:write, tasks:read, tasks:write, tasks:approve, and events:read. Group, task and event scopes remain reserved for their corresponding public endpoints. Scopes never add permissions to their creator: each operation must also enforce current workspace and resource access. Removing a workspace member permanently revokes their tokens, including if they later rejoin. Only SHA-256 digests are persisted, and creation, permitted or insufficient-scope use, and revocation produce audits without credential material.

Public Bot clients use GET /v1/bots, GET /v1/bots/{botId} and the nested /versions and /versions/{versionId} routes with bots:read. bots:write enables POST /v1/bots, PATCH /v1/bots/{botId} and POST /v1/bots/{botId}/archive. Each scope is explicit; neither implies the other. The token's bound workspace is authoritative. Current direct Bot permissions control configuration access, editing and owner-only archive, just as in the Web UI.

The OpenAPI 3.1 contract describes request and response schemas, pagination and safe permission errors. Updates require expectedCurrentVersionId and preserve omitted fields and avatar references; a stale version returns 409. Bot pages use an exclusive after UUID cursor, and version pages use an exclusive before version number. Both accept limit from 1 to 100 (default 50). API writes and UI edits share the same Bot identity and immutable versions; archive preserves that history. Tokens are checked again in the resource transaction before it commits, including after lock or audit waits.

Public Group clients use GET /v1/groups and GET /v1/groups/{groupId} with groups:read. groups:write enables POST /v1/groups, PATCH /v1/groups/{groupId}, membership and Bot grant routes, routing updates, and POST /v1/groups/{groupId}/archive. Neither scope implies the other. History access on a Bot invitation is future, since, or all, and defaults to future. Group pages use the same exclusive after UUID cursor and limit of 1 to 100. The Group OpenAPI 3.1 contract covers those operations. Archived groups stay inspectable and reject further management writes.

Personal model connections

After signing in, select Personal models in the workspace, or open http://localhost:3000/app/settings/models. Before enabling connections, generate a key with openssl rand -base64 32, set OPENBOT_PROVIDER_ENCRYPTION_KEY in .env, and restart the API and worker. Preserve that key across restarts and backups; changing it makes existing credentials unreadable. An empty key disables connections while the rest of the instance remains available.

OPENBOT_PROVIDER_ALLOWED_HOSTS is a comma-separated list of exact endpoint hostnames and defaults to api.openai.com. Schemes default to HTTPS; opt into HTTP through OPENBOT_PROVIDER_ALLOWED_SCHEMES only for explicitly trusted local providers. A private address also requires its CIDR in OPENBOT_PROVIDER_PRIVATE_CIDRS. Every resolved address is checked before connecting, DNS answers are pinned for the request, and redirects are rejected. For a local provider, allow its hostname, scheme, and private CIDR explicitly.

Create a connection with an explicit protocol (OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages), a name, base URL (for example https://api.openai.com/v1), model ID, optional API key, and optional custom headers as a JSON object. Test and save first probes a live text stream and a structured action, then stores timestamped, sanitized evidence. Failed text probes save a disabled connection; working text remains usable when structured actions are unsupported. Existing connections default to Chat Completions; protocols are never guessed from the endpoint. All adapters normalize text, live deltas, function actions, usage, and completion through a shared model-event contract with cancellation and rate-limit classification. Settings allow inspection, editing, retesting, disabling, and deletion. Blank secret fields on edit retain existing values; use Clear saved API key or {} headers to remove them.

Anthropic connections use the anthropic-messages protocol, send the API key as x-api-key, and append /messages to the configured base URL. Set Anthropic version (anthropicVersion in the API) to the compatibility endpoint's supported version; the default is 2023-06-01. Custom headers cannot override that version or duplicate a supplied API key. Streamed local tool_use blocks become structured actions; hosted tools and Computer Use are not executed.

API keys and all custom header values use AES-256-GCM encryption with per-save random nonces and owner/connection binding. Reads expose only configured markers and header names. Personal connections are available exclusively to their owner. The authenticated API is rooted at /api/v1/model-connections; mutations require the configured web Origin and a session cookie.

Bot identities

Select Bots in a workspace to create a private Bot with a name, a role description, system instructions, an available model, and default execution limits. The role description (up to 2,000 characters) is what people see and what group routing matches a task against; it is never sent to the model. The system instructions are the model's system message: every task context starts with them, then group or Bot memories, then knowledge, then the conversation itself. The free-text description field is no longer collected in the app; the API keeps it for templates and older clients. Each limit (total tokens, duration, turns, delegation depth) is a bounded integer or Unlimited, which the API stores as null: the Bot then sets no ceiling of that kind, while workspace and group execution policies still apply. The limits fieldset offers No limits and Restore defaults as one-click presets. A Run without a duration ceiling keeps a seven-day lease so a lost worker is still noticed. Creation records immutable version 1 and makes the creator its owner. The detail page shows the saved configuration and the model's current availability. Basic-only models are labeled chat-only. Workspace administration does not grant access to another user's private Bot.

Bot owners can open Manage permissions from the detail page to grant owner, editor or user access and choose private or workspace discovery. Discovery exposes summary information only; configuration and use require an explicit Bot grant. The last owner with current workspace access cannot be removed through Bot permissions. Workspace removal immediately disables access while retaining the grant; explicit Bot revocation removes it. Neither setting changes version history.

Bot owners can open Manage lifecycle to archive a Bot, restore an archived Bot, or soft-delete it. Archived Bots keep their identity and history but cannot start new work. Soft-deleted Bots are hidden from the default list; their owners can open the deleted-Bot view and undo deletion during the fixed 30-day recovery period. Recovery preserves the state that preceded deletion and checks model access again before returning a Bot to active use.

People with direct Bot access can open Copy configuration, review the source configuration and choose an accessible model. A copy starts as a new private active Bot with version 1 and only its creator as owner. It does not copy permissions, conversation history, private memory or credentials.

Conversations and tasks

Conversations is one chat list for every group and Bot you can talk to: a group opens the group conversation, a Bot opens your private conversation with it. Rows show the last message, who is writing, and an unread dot; the list comes from GET /api/v1/workspaces/{workspaceId}/conversation-activity, and opening a conversation posts POST …/conversations/{conversationId}/read so the dot clears. Groups and Bots are created and managed from the links at the bottom of the list; their pages keep their addresses.

In a private Bot conversation every message runs the Bot. In a group, @Bot in the composer sends the message to that Bot as a task (one Bot per message); a message that names nobody goes to the Bot the routing decision picks, so nobody has to learn the mention syntax. A group with no usable Bot keeps its messages between people, and the composer says which of those will happen before anyone sends. Files always go out as a message. Tasks appear as cards in the flow right after the message that started them, with approve / reject, answer, pause, cancel and retry on the card; a running Bot streams its draft under the card, and its reply carries the task id. Clicking any Bot avatar, in any conversation, opens that Bot in the details panel: its picture, name, role description and model state, editable in place by anyone who may edit it, and the picture itself is the upload control (a static PNG or JPEG up to 2 MiB). Saving records a new configuration version, and the deeper settings keep their own pages. The profile is fetched on click, so rendering a thread never reads Bot configuration, and someone without access sees the Bot's messages with a generated avatar and a note instead of the profile. Bots and groups without an uploaded picture get a stable generated avatar derived from their id, so a rename never changes the face. The details panel holds the Bot, group info, the task index, routines and the memory queue. From 90rem wide it is a resident column rather than an overlay, because the space beside the thread is there: it opens with the conversation and the ⓘ control dismisses it, giving the width back to the thread. Below that width it stays a panel opened on demand, and the conversation surface is the one page that uses the whole window instead of the reading width. A workspace provisions one built-in assistant the first time a group message names no Bot. It is an ordinary Bot underneath, with its own versions and audit trail, bound to the workspace's oldest shared model connection and held to a single turn, 2,000 tokens and no delegation. The workspace owner owns it and can open, edit and roll back its configuration through the ordinary Bot pages, but it appears in no list a person picks from: not the Bot list, a group's Bot list, the mention picker or a copy source, and it is never a lexical routing candidate. A workspace that shares no model connection provisions none.

The assistant leads a group message that names nobody: it joins the group through a hidden membership, reads the thread from the beginning, and its run is told which Bots are here and what each of them is for. It then either answers under its own name, with its own avatar and the Bot tag, or hands the task to the Bot whose role fits, in the same task, through the handoff every Bot already uses. The specialist answers in its own name and the task card names both. The assistant never hands a task to itself, never appears as a candidate to score, and a message that names a Bot never reaches it. A group with no Bot to hand to keeps its messages between people. A configured default lead becomes the Bot the assistant is told the group prefers, handed the task unless the message plainly falls outside that role, so a deliberate choice still counts without misdirecting an unrelated question. Why this Bot? on the card shows the assistant as the reason, with nothing scored.

Talk between people costs no Bot answer. When the assistant judges that no Bot should reply, the task ends as not adopted: no Bot message, no error, no turn counted, no retry, and no notification. The message carries one quiet line saying it was not handed to a Bot, with the reason on hover, and the task index shows it as not adopted rather than as a failure. The tokens the decision itself used are still recorded against the budgets. The database refuses a not-adopted run that also wrote a Bot message, refuses anything after one, and admits the decision only from the built-in assistant in a group: a Bot that was asked for an answer cannot end a task quietly.

A group decides whether the assistant listens at all. The switch sits with the group's routing settings, is on by default, and only a group owner or admin can change it, under the same optimistic concurrency as the default lead; the page shows its state and who last changed it. Switched off, a message that names no Bot stays a message between the people there and creates no task, while a mentioned Bot still runs. Clearing the default lead never changes it. A workspace owner who archives the assistant turns it off everywhere, and automatic routing falls back to local matching.

The details panel's Tasks tab lists the conversation's tasks; opening one shows the saved task in full — attempts with model and usage, budgets, interrupted output, the routing decision, the cancel / pause / resume / decide forms with their unchanged-command confirmations and a retry for a failed task — at …/conversations/{conversationId}/tasks/{taskId}, which renders the conversation with the panel open. …/tasks opens the panel on the list and …/tasks/{taskId}/runs opens the task; both are kept as addresses only. Routing follows the same rules everywhere: an explicit mention selects that membership, otherwise the built-in assistant reads the message and decides, and where the assistant is switched off or has no model the group's default lead answers when eligible, followed by a local match against eligible Bots' public profiles. Why this Bot? on a card opens the saved routing decision. PostgreSQL retains the Task and its Runs, and a separate worker executes the pinned Bot version using the triggering user's current model permissions.

The web runtime image carries the built server bundle and no node_modules, so anything the server renders with is bundled into it: pnpm --filter @openbot/web build refuses a bundle that imports a package the image would not have, which is what keeps a dependency like the Markdown renderer from turning every conversation page into a 500 in production while the dev server stays happy.

A Bot's answer reads as formatted prose: headings, lists, task lists, tables, quotes, inline code and fenced code blocks from a bounded Markdown subset, rendered from an allowlist so raw HTML, scripts and event handlers appear as visible characters and load nothing. Only http and https links survive, opening in a new tab without referrer or opener; a picture becomes a link to its source, because an answer never loads a remote resource. A person's message, every one-line preview and a run's interrupted output stay literal. Formatting is present without JavaScript; the copy control on a code block is added by the browser. While a Bot writes, each finished block appears formatted and the unfinished tail stays literal until it closes, so the layout never jumps.

A Bot can hand over a design artifact: a fenced block marked artifact svg or artifact html, followed by a title, becomes a card in the conversation with the artifact's name, size, a preview, and controls to open or download it; an ordinary html or svg code block is still code, and an unterminated one stays code until it closes. Opening the card shows the artifact full size in the details panel. The artifact lives inside the Bot's own message, so it inherits authorship, retention, purge, export and memory rules with no new storage, and the answer's own size limit bounds it. Every preview is served from …/conversations/{conversationId}/messages/{messageId}/artifact/{index} under a policy that sandboxes the document and forbids every resource, and it is framed with sandbox as well, so it is inert whether it is framed or opened directly; adding ?download returns the Bot's own source as a file. A card also says which artifact it is within its task and links to the earlier ones.

Every run is told how to do this, the same way it is told which tools it may call: a few lines appended to the Bot's own system instructions say when to produce an artifact, how to mark it, and not to mark ordinary code that way. Nothing has to be configured per Bot, and the wording never reaches a reader.

Live draft text and Run updates resume after reconnecting and converge on one saved Bot response. The original requester can manually retry a failed Task as a new Run on the same Task and inspect its attempt history. Reloading shows the saved status, actual model, usage and final response. In a group conversation, Save as group memory records a reference to a current human or Bot message; Group memories opens its source details and scoped search.

The worker uses the same provider encryption key and network policy as the API. Without a key, it reports task_worker_unconfigured and leaves queued work untouched. Configure the key and restart it to resume the queue. See Task worker operation for startup, shutdown, and the limits of this execution slice.

Workbench

Signed-in pages share one app shell: a sidebar with the workspace switcher and the workspace, build, manage and account navigation (collapsible to an icon rail on desktop, a drawer behind the top bar's menu button on phones), a top bar with the theme menu and sign-out, and the page itself. A workspace opens on its conversation list, which is also the inbox: a count line (running, needs you, unread), a Needs you block with every approval and question waiting on the person (each row jumps to the task card in its conversation), and the conversations themselves with their unread dot and @ marker. The block and the counts read GET /api/v1/workspaces/{workspaceId}/overview, a session-only aggregate that owners and administrators see for the whole workspace and members see for their groups and their own direct conversations. /app, /app/workspaces/{workspaceId}, …/approvals and …/notifications all land on the list; workspace name, description and creating another workspace live at …/settings. The palette follows the system light/dark setting; the top-bar theme menu pins one by setting the openbot_theme cookie (light or dark; "Follow system" clears it), which the server applies as data-theme on the first byte. Design tokens live in apps/web/src/lib/styles/tokens.css; the Newsreader and Mulish fonts are served from apps/web/static/fonts under the SIL Open Font License.

Install as an app

The web app is an online-first PWA. Browsers offer to install it from /manifest.webmanifest (standalone window, /app start URL, SVG and PNG icons), and a service worker caches only the build assets and the /offline page. Every navigation and API request goes to the network; when the instance is unreachable a page load shows the offline shell and a submitted form is answered with a 503 offline status, so nothing is ever reported as saved from cache. Every rendered page carries a Content Security Policy that admits scripts only through a per-response nonce, styles and fonts only from the instance, images only from the instance and inline data, and refuses to be framed; the prerendered offline page carries the same policy as a meta tag. The layout carries a skip link, an online/offline banner, always-visible keyboard focus outlines and phone-width rules (tables scroll inside their box, headers wrap), and the notification stream resumes from Last-Event-ID after a reconnection. pnpm --filter @openbot/web exec vitest run tests/unit/accessibility.test.ts audits the core pages and palette contrast; tests/e2e/pwa.spec.ts covers installability, offline behaviour, phone and desktop layouts and axe WCAG scans in CI's Chromium (locally: playwright test --config playwright.local.config.ts).

Routines

Open the group conversation's details panel and its Routines tab to schedule one bounded collaboration run; the composer's schedule button opens the same form with the draft as the prompt and an @-mentioned Bot as the Lead. …/groups/{groupId}/routines opens that tab. A routine stores its owner, group, prompt, routing policy, IANA time zone, execution time, cost budget, and expiration. Times are entered as wall time in the routine's own zone, so the browser's zone never changes the schedule; a wall time that a forward DST transition skips is refused before it is saved. Choosing a group Bot pins that membership as the Lead; leaving it empty uses ordinary group routing.

At its scheduled time the task worker creates exactly one standard collaboration task and links it from the routine. A database uniqueness constraint on the routine occurrence keeps concurrent workers from creating duplicates, and a service restarted at trigger time recovers one unexpired execution without repeating it. Group members can edit, pause, resume, and cancel a routine while it is still active or paused; a routine disables itself at its expiration.

Bot collaboration actions cannot create routines, and cannot raise an existing routine's budget or frequency. Bots are offered exactly four collaboration tools — request_input, request_approval, delegate, and handoff — and the routine service refuses any Bot-attributed create or escalation.

The same routines are available to workspace API tokens holding groups:read or groups:write:

curl -X POST http://localhost:3001/v1/routines \
  -H "authorization: Bearer $OPENBOT_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"groupId":"<group>","prompt":"Prepare the Monday brief.","timeZone":"Asia/Shanghai",
       "executeAt":"2027-03-01T01:00:00.000Z","expiresAt":"2027-03-04T01:00:00.000Z",
       "maxCostMicros":2500000}'

GET /v1/routines/:id reads one routine with its created task, PATCH /v1/routines/:id edits it, and POST /v1/routines/:id/{pause,resume,cancel} moves it between states.

Repeating cron routines

Choosing Repeating (cron) takes a five-field expression — minute, hour, day-of-month, month, day-of-week — with *, ranges, lists, and */n steps; 7 means Sunday, and second-level cron is not supported. Pass cronExpression instead of executeAt to POST /v1/routines. Every occurrence is resolved as wall time in the routine's own zone, so a schedule keeps its local hour across daylight-saving changes: an hour a forward transition removes is skipped rather than shifted, and an hour a backward transition repeats fires exactly once.

The tab lists the next execution and the latest result, with a bounded run history whose outcomes distinguish completed, failed, and cancelled tasks from a tick that produced no task at all — expired, or skipped_overlap when the previous task was still running. A skipped tick creates no task and the schedule simply advances. After an outage, ticks older than five minutes are history: the routine logs routine.missed, backfills nothing, and resumes at its next future occurrence. When no occurrence is left inside the routine's window it disables itself and creates no later task.

Notifications

Notifications are content-free pointers stored per recipient: a task waiting for approval, a task that completed or failed, a task stopped by an exhausted budget, and a direct mention. A pending approval reaches every human member of the group that can answer it; a terminal or budget outcome reaches the person whose request was waiting; a mention reaches the member named. Rows are written inside the producer's own transaction, so a notification never outlives a rolled-back outcome, and one source event can produce at most one row per recipient no matter how often a producer retries or a stream replays.

The conversation list shows them. Each row's unread and mentioned flags come from GET /api/v1/workspaces/{workspaceId}/conversation-activity and follow the person's level for the group: all marks any message by someone else and any notification; mentions_only marks only a mention or a notification about the person's own task; muted never shows the unread dot, though the @ marker still appears. Direct Bot conversations behave like all. Opening a conversation posts POST …/conversations/{conversationId}/read, which records the read mark and, in the same transaction, reads the conversation's notifications: mentions up to the read sequence and every task notification. The browser tab title carries the count of unread and waiting items.

The list is live. The web app subscribes to the workspace event stream through the web server (/app/workspaces/{workspaceId}/notifications/events, which proxies GET /v1/events and forwards Last-Event-ID on reconnection) and re-reads the list when a conversation.updated, task or mention frame arrives. conversation.updated is appended in the same transaction as any message creation, edit or deletion; the frame names only the conversation, scoped to its group or, for a private Bot conversation, to its one reader, so nobody receives more than "something changed". The open conversation records its read mark as messages arrive while the tab is visible; a background tab catches up when it is seen again.

The notification API itself is unchanged: GET /api/v1/workspaces/{workspaceId}/notifications paginates newest first with an unread count, entries can be marked read one at a time or all at once, and opening an entry asks the API to recheck the reader's current authorization before it hands back a destination, so a notification kept from a group the reader has since left leads nowhere. The workspace event stream still carries new notifications for API consumers.

Mentions and per-group levels

In a group conversation, @ in the composer names other current members by their stable member id, so renaming someone never changes who is notified. A mention reaches each chosen member who is still in the group — never the author. Only current human members can be mentioned; a removed member, a stranger, or a Bot grant is refused before anything is stored. Mentioning a Bot routes the message to it as a task and creates no human notification.

Every member sets their own level per group in the conversation's details panel (Info tab, Notification level): all, mentions_only or muted, as described above. Levels are read when an event is published, so a change applies to the next event, and a member the group has removed receives nothing new from it. Private Bot conversations have no level.

Workspace data export

Workspace owners and administrators can request a data export under Data export in workspace settings. The task worker builds one uncompressed tar archive containing the workspace configuration, members, Bot configuration and versions, group conversations with their messages and mentions, tasks and runs, routines and their occurrences, notifications, memory metadata, and every live attachment. export.json names the schema version and creation time; manifest.json lists every file with its size and SHA-256, and the export record keeps the archive's own SHA-256.

The archive never contains password hashes, sessions, API tokens, provider credentials, or key-wrapping material: those tables are never read, and any column whose name can hold or recover a secret is dropped from every exported row. Export failures are recorded with an error code and can be retried as a new attempt; a finished export cannot complete twice. Archives are deleted 24 hours after completion, while the content-free record — outcome, size, checksum, timestamps — remains in the audit trail alongside workspace.export_* events.

Workspace deletion and recovery

A workspace owner deletes a workspace under Delete workspace in workspace settings. The confirmation page offers the data export first and shows the exact purge_after time a deletion confirmed now would record; typing the workspace name confirms. Deletion marks the workspace and nothing else: every access check reads that mark, so ordinary members lose the workspace at once through the UI, the REST API — including API tokens — and the event stream, while all content, attachments, memory and indexes stay untouched until the grace period ends.

During the grace period (WORKSPACE_DELETION_GRACE_DAYS, default 30) the workspace owner or the instance administrator can restore the workspace with every visible state intact; afterwards the final purge, a later ticket, removes it. Repeated or concurrent delete and restore requests converge on one state, and workspace.deleted / workspace.restored audit records carry only the workspace id and the timestamps involved.

Purge manifest (expand phase)

Final purge is not enabled yet. What exists is its plan: WorkspacePurgeService.dryRun lists every relational table a purge would clear — Bots, groups, messages, tasks and runs, routines, notifications, memory metadata, knowledge rows, API tokens and the event stream — with the handler version and an estimated row count, without writing anything. Executing a manifest requires a workspace that is soft-deleted and past its purge_after; a live, still-restorable, or restored workspace is refused. Each table step runs in its own transaction and is recorded, so a failed purge resumes after the last completed step, a completed manifest is repeated without deleting anything new, and every scope is derived from the workspace id so a neighbouring workspace is never touched. Object-backed data is part of the same manifest: the attachment, avatar and export-archive handlers delete every stored object — keyed by the workspace being purged, never by the row — before the rows that name them, and the connections handler removes provider ciphertext with the key-wrapping material sealed inside it. Knowledge rows carry the full-text index, so removing them removes the index; this codebase keeps no separate summaries, vector index or cache. A store failure fails that step with a store_* code and a retry finishes what remains, while objects already deleted stay deleted and a neighbouring workspace's objects are never touched.

Retention and final purge

The instance administrator sets the workspace retention period under Instance settings (1–3650 days, default 30); every deletion confirmed afterwards records purge_after from it. The task worker's retention pass takes one soft-deleted workspace past its purge_after per tick, runs or resumes its manifest, and finalizes only when every step completed: memberships, invitations, the manifest and the workspace row go, and all that remains is a receipt — an irreversible fingerprint of the workspace id, the deletion and purge timestamps, who requested it, the result and per-handler counts. A workspace never reaches final purge before purge_after, after a restore, or with an incomplete manifest, and once purged it cannot be listed, exported, restored or read through the API or the event stream.

Local development

Node.js 24 and pnpm 11 are required.

corepack enable
pnpm install --frozen-lockfile
cp .env.example .env
pnpm db:migrate
pnpm --filter @openbot/api dev
pnpm --filter @openbot/web dev

For local processes, provide PostgreSQL at the DATABASE_URL in .env. The web server reads API_BASE_URL and never exposes database connection details to the browser.

Verification

pnpm verify

This runs formatting checks, strict TypeScript and Svelte checks, unit tests, HTTP integration tests, Playwright coverage for readiness, local authentication, and a signed mock OIDC provider, and production builds.

CI additionally runs the real PostgreSQL authentication and personal-provider invariants. With a disposable PostgreSQL database, the same test can be run manually:

TEST_DATABASE_URL=postgresql://openbot:password@localhost:5432/openbot_test \
  pnpm --filter @openbot/api run test:postgres

Provider persistence and runtime privilege checks use a separate disposable database:

TEST_PROVIDER_DATABASE_URL=postgresql://openbot:password@localhost:5432/openbot_providers_test \
  pnpm --filter @openbot/api run test:postgres

Docker is also required to validate docker compose up --build itself. The Compose stack runs a one-shot, idempotent migration and privilege service as soon as PostgreSQL is healthy. The API does not start during initial stack startup until the migration and privilege gate succeeds, so the first API process receives only the restricted runtime role. Run later schema changes during a maintenance window that restarts the API and worker after the migration service finishes. Once started, the API remains running and its readiness endpoint reports Unavailable during a later database outage. The web service starts after the API container. PostgreSQL and migrations live on an internal data network; the web container can reach only the API over a separate frontend network.

License

OpenBot is licensed under the GNU Affero General Public License v3.0 only.

OIDC runtime privilege checks use their own disposable database and the deployed grant script:

TEST_OIDC_DATABASE_URL=postgresql://openbot:password@localhost:5432/openbot_oidc_test \
  pnpm --filter @openbot/api run test:postgres

The postgres-auth CI job runs OIDC callback/invitation concurrency, transaction rollback, and session-revocation checks. The postgres-oidc job verifies link, sign-in, invited registration, last-credential protection, and rollback using the restricted runtime role. These real PostgreSQL checks supplement the in-memory SQL browser fixture and must pass before release.

Bot avatars

Bot owners and editors can upload or remove an avatar from the Bot detail page. Static PNG and JPEG files are limited to 2 MiB, 4096 pixels per side and 4,194,304 pixels total. The server fully decodes the image, applies orientation, removes metadata, and writes a PNG fitted inside 512 × 512. Other formats, animation, malformed data and oversized images are rejected.

Each successful change appends an immutable Bot version. Concurrent stale edits return a conflict; reload before retrying. Earlier versions retain their avatar objects. Workspace discovery shows a default image; reading uploaded bytes requires current workspace membership and Bot inspection access. Images are served through authenticated same-origin routes with private no-store headers.

Compose stores private objects in the object-data volume mounted at /var/lib/openbot/objects, owned by the non-root API user. Back up that volume with PostgreSQL. For local pnpm dev, create an absolute directory owned by the API user, restrict it to mode 0700, and set OBJECT_STORAGE_LOCAL_PATH. The Web adapter requires BODY_SIZE_LIMIT=3M to accept a 2 MiB image plus multipart overhead; downstream image limits remain unchanged.

For S3-compatible storage set OBJECT_STORAGE_BACKEND=s3, bucket, region, access key ID and secret; set the endpoint for a compatible service and session token when required. The operator must provision a private bucket and keep public access disabled. The service uses scoped immutable keys, conditional writes and bounded I/O; it does not issue public URLs or ACL grants. Credential rotation preserves object identity. Changing the local path, endpoint, bucket or region requires an explicit data migration: existing objects fail closed rather than silently switching stores.

A bounded minute-based cleanup worker retries abandoned uploads and failed deletes. Historical version references prevent deletion; tombstones reconcile late remote writes. Physical deletion of retained Bot history awaits the data-retention lifecycle. Native PostgreSQL and real S3 contract tests run as separate CI gates; local wire mocks do not replace those checks.

About

An open-source, model-agnostic implementation of Grok Bot, designed for seamless multi-LLM orchestration.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages