Audience: LLM implementers and developers building or extending the Safebots plugin for Qbix. Safebots adds configurable behaviors to existing users — a community's chat publisher gets inline suggestion generation, artifact authorship, and deliberative coordination as features of the publisher, not as a separate bot entity. This document assumes familiarity with Safebox.md.
- Overview & Philosophy
- Plugin File Structure
- Stream Types
- Bots — The Core Abstraction
- The Publisher-Augments Model
- Attribution —
from @source via @agent - Safebots/suggest — Inline Suggestions
- Safebots/goal — Goal Streams
- Safebots/dialog — Dialog Streams
- Storefront Greeting
- Relations as Emissions — Channels
- Artifact Lifetime & Refcount Model
- Voting, Versions & Promotion
- Safebots/goal/chat — JS Chat Extension
- Capability & Tool Generation from Chat
- PHP Implementation Guide
- JavaScript Implementation Guide
- Node.js Implementation
- UI Reference
- Install & Seed
- Appendix: Configuration Reference
Safebots is the behavior layer on top of Safebox. Where Safebox handles infrastructure — stream materialization, workflow execution, action governance, economics — Safebots handles the LLM-guided behaviors that communities and users attach to their own streams: inline suggestions in chats, artifact authorship, deliberative coordination, policy-summarization, auto-voting.
Earlier drafts of this plugin treated bots as a separate kind of user, with their own identity, access rows, subscriptions, and governance. That model introduced duplication at every layer — duplicate access rows, duplicate identity keys, duplicate billing contexts, duplicate attribution fields. Each layer kept asking "who is this?" and the answer kept wanting to be "the community."
The current design uses composition. A user (community or individual) can
publish Safebots/bot/* streams declaring behaviors they want active on
their own streams. When events happen on a user's streams, the plugin's hooks
consult the publisher's bot configuration and dispatch accordingly.
There is no bot user. The community is its own bot, in the sense that
bot-assisted activity on the community's streams runs under the community's
identity.
When a message is posted in a chat, Safebots dispatches to the chat's publisher's behaviors. Not the poster's behaviors. Not the reader's behaviors. The publisher's. This matches the Telegram inline-bot pattern: inline bots are a property of the chat, enabled by the chat's owner; they augment what participants can produce. The same pattern here. A community that enables the suggest behavior on its chats has inline suggestions available in those chats. A community that disables it doesn't.
Individual users who want personal bots publish their own bot streams:
{communityId} / Safebots/bot/suggest ← community's inline-suggest config
{communityId} / Safebots/bot/greet ← community's onboarding-message config
{alice} / Safebots/bot/daily-summary ← Alice's personal daily recap
The shape is identical; the difference is only the publisher.
Safebots doesn't create bot users. Safebots is a mechanism for existing users to declare automated behaviors on their own streams. A chat's behaviors are the chat publisher's behaviors. A user's behaviors are the user's behaviors. There is no bot user to provision. There are no Safebots-specific access grants in the installer, because the publisher already has full access to its own streams.
When an event happens on a stream — a message is posted, a stream is created,
a relation is proposed — the Safebots dispatcher looks up the stream's
publisher, finds the publisher's active behaviors matching the event, and
invokes each matching behavior's referenced Safebox/tool. The tool runs
inside the Safebox sandbox with the publisher's billing context, makes
whatever LLM or capability calls it needs, and either writes directly (if
the publisher's access permits) or emits Actions.propose(...) for
governance. The result flows back to the triggering caller or appears as
normal chat messages, attributed to the publisher.
The composition model is structural, not cosmetic. A bot, in this system,
is a stream — or more precisely, a small composition of them. The
bot configuration is a Safebots/bot/* stream. The policies
governing when the behavior writes autonomously versus proposes for review
are Safebox/policy/* streams. The tools the behavior references are
Safebox/tool/* streams. The keys authorizing amendments to any of these
are entries in Safebox/keys/* streams. Every structural element of what a
community informally calls "its bot" is, formally, a stream in the Qbix
substrate.
This matters because Qbix Streams — described in detail as a graph database hiding in plain sight — already handles most of what bot governance would otherwise need to reinvent:
- Access control comes from
Streams_Accessrows, same as any other stream. Who can read, edit, or admin a bot configuration is a normal Qbix permissions question. - Collaboration comes from the same discussion-then-decision flow
Qbix runs for documents. Multiple community members contribute to a
behavior's development; amendments go through
Actions.propose. - Relationship voting comes from the substrate's built-in vote machinery on relations. Two candidate versions of a behavior can run in shadow; the community votes on which produces better output; the winner gets promoted through the same curatorial process used for any other artifact.
- Forking with provenance comes free. A community forks another community's audited behavior, re-audits only the diff, and adopts the customized version under its own governance.
- History and audit come free. Every change to a behavior is a message on the behavior's stream, replayable, attributable.
- Federation comes free. A bot in Community A can reference a tool published by Community B, each side under its own governance.
None of this required building bot-specific infrastructure. Fifteen years of substrate maturity does the work that would otherwise require fifteen years of AI-specific infrastructure reinvention. The category error most AI-agent architectures make is assuming bots need their own identity system, their own memory store, their own governance model, their own tool-use protocols. They don't. Make the bot a stream, attach behavior to it, let the substrate handle the rest.
For the longer-form take on why this architecture works — and how it maps to the three Chabad faculties of Chochmah, Binah, and Da'at (Grokers, Safebots, Safebox) — see the wisdom essay.
| Safebox does | Safebots does |
|---|---|
| Workflow/step/task orchestration | Define bot streams and dispatch hooks |
| Stream materialization, caching, commissions | Invoke bot tools via Safebox sandbox |
Actions.propose governance pipeline |
Compose behavior output into Actions.propose payloads when governance is needed |
Streams.fetch / Streams.related sandbox API |
Use these in behavior tool code |
| Protocol.* (LLM, SMTP, Telegram, etc.) | Behavior tools call these for their work |
| Sandboxed tool execution with sha256 attestation | Reference audited tools from bot streams |
| Billing via community's Safebux balance | Inherit — the community is its own payer |
Safebots is the small plugin. Most of the architectural weight is in Safebox; Safebots is the application layer that configures how a publisher's streams get augmented with LLM-guided behaviors.
What actually happens when a community's suggest behavior fires on a chat:
event on Streams/chat stream (e.g. suggest-request)
│
▼
┌─────────────────────────────────────────┐
│ Safebots dispatcher (in PHP) │
│ looks up stream.publisherId's │
│ Safebots/bot/suggest │
└─────────────────────────────────────────┘
│
▼ (sendToNode reply-mode IPC)
┌─────────────────────────────────────────┐
│ Safebox sandbox (in Node) │
│ loads behavior.tool (sha256-verified) │
│ runs tool with event payload │
└─────────────────────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼ ▼
Streams.fetch Protocol.LLM Actions.propose
(read) (think) (write, if governance approves)
│ │ │
└──────────┴──────────┘
│
▼
┌─────────────────────────────────────────┐
│ Result returns to PHP → client, │
│ OR appears as a new chat message │
│ authored under publisher identity │
└─────────────────────────────────────────┘
The pattern is the same for any behavior: event hits a stream, dispatcher looks up the publisher's matching behavior, tool runs in the sandbox, result flows back. There is no bot user in the flow. There is no special access path. There is no Safebots-specific governance. The substrate's existing primitives do the work.
Safebots/
├── README.md # this file
├── config/
│ └── plugin.json # stream types, routes, event hooks
├── scripts/Safebots/
│ └── 0.1-Streams.mysql.php # seed: platform goals, default bots,
│ # Safebox activation policy
├── classes/
│ ├── Safebots.php # displayNameOf, modelFor,
│ │ # ensureActivationPolicy, defaultActionTypes
│ ├── Safebots.js # Node-side addMethod handlers + tool shims
│ └── Safebots/
│ ├── Bot.php # bot enumeration + dispatch + seedDefaults
│ ├── Goal.php # goal-prompt interpolation
│ └── Dialog.php # dialog stream creation + completion
├── handlers/
│ └── Safebots/
│ ├── after/
│ │ ├── Q_Plugin_install.php # bootstrap seed on install
│ │ └── Streams_postMessages.php # universal dispatcher hook
│ ├── suggest/response.php # thin PHP dispatcher → Node (reply mode)
│ ├── dialog/response.php # create dialog stream
│ ├── dialog/complete.php # mark dialog done
│ ├── goal/respond/response.php # user posted chat → trigger LLM response
│ ├── goal/message/response.php # Node calls back with LLM output
│ └── internal/setAttribute/response.php # node-internal shim endpoint (compat)
├── text/Safebots/content/en.json
└── web/
├── css/Safebots.css
└── js/
├── Safebots.js # browser bootstrap + chat extension registry
└── tools/
├── goal/chat.js # goal-directed chat extension
└── safebots/chat.js # inline-suggest extension + storefront
Handler registration. Two event hooks registered via plugin.json, no
direct per-stream-type file autoload required:
{
"Q": {
"handlersAfterEvent": {
"Q/Plugin/install": ["Safebots/after/Q_Plugin_install"],
"Streams/postMessages": ["Safebots/after/Streams_postMessages"]
}
}
}Streams/postMessages is the universal dispatcher subscription — fires
once per substrate message batch, filters in-memory against each
publisher's active bots and their handler maps (see §4 "How dispatch
works"). Previous iterations registered per-event handlers for every
message type any bot might care about; collapsing to the single
postMessages hook removes that coupling and lets communities author
bots reacting to new message types without touching plugin configuration.
| Type | Shape | Publisher |
|---|---|---|
Safebots/bot |
Declares a bot: one or more handlers, each a (message type → tool) mapping |
user (community or individual) |
Safebots/goal |
Interpolated system-prompt template for goal-directed chats | user (or Users::communityId() for platform-default goals) |
Safebots/dialog |
In-progress guided-conversation session | community |
Safebots/artifact |
Content being produced or revised in a chat | community |
Platform-default goals are published under Users::communityId() (the hosting
app's community) so that all communities on the deployment can reference them
via the community-fork-then-platform-fallback pattern in
Safebots_Goal::findGoalStream.
A bot is a stream whose attributes carry its whole configuration. Two attributes define everything:
Safebots/active : true | false ← whole-bot on/off
Safebots/handlers : { ← map keyed by message type
"Streams/created": {
"streamType": "Streams/chat",
"tool": "Safebox/tool/Safebots/greet/Streams_chat_created",
"active": true
},
"Streams/relatedTo": {
"streamType": "Streams/chat",
"fromType": "Safebots/goal",
"tool": "Safebox/tool/Safebots/greet/goal_attached",
"active": true
}
}
The handlers map is keyed by a substrate stream-message type (the type
column of streams_message, e.g. Streams/created, Streams/joined,
Streams/chat/message, Streams/relatedTo, Safebox/action/approved).
Each entry declares a single handler with these fields:
| Field | Purpose |
|---|---|
tool |
Safebox/tool/{path} stream reference; audited code the sandbox runs |
active |
Per-handler on/off — lets a bot keep a handler defined but quiesced |
streamType |
Filter: only fire when the affected stream matches this type |
fromType |
Filter for relate-messages: the type of the OTHER stream involved |
allowSelfAuthored |
Override the default "don't re-dispatch messages authored by the stream's publisher" guard |
Both the bot's Safebots/active AND the matching handler's active must
be true for a handler to fire — the two flags represent different
concerns. Whole-bot disable lets a community pause all of a bot's
behaviors without editing the handlers out. Per-handler disable lets a
bot author keep a handler defined and readable while turning off just
that one reaction.
Booleans are stored as actual booleans (true/false), not strings.
Qbix's attribute system round-trips native JSON types; getAttribute
returns the type it was set with.
Bots are related to Safebots/bots (the index category) for enumeration.
The relation type is uniformly Safebots/bot:
Streams::relate(
$publisherId,
$publisherId, 'Safebots/bots', // to: the index
'Safebots/bot', // relation type
$publisherId, $botStreamName, // from: the bot
array('skipAccess' => true, 'weight' => time())
);The dispatcher fetches all bots for a publisher in one query and filters
in-memory against each bot's Safebots/handlers attribute. Using a
per-bot relation (rather than per-handler) matches the mental model: a
bot is a bot, not a collection of handler declarations. For most
publishers the bot count is small (5–20), so the in-memory filter is
cheap. If ever a publisher carries hundreds of bots, we can add a
secondary index keyed by event type.
A bot is a declaration that says: when one or more events happen on my streams, run these tools. The events are substrate stream-messages; the tools are audited Safebox tool streams. One bot can listen to multiple event types — each forms a coherent role the community has configured as a unit.
$handlers = array(
'Safebots/suggest/request' => array(
'streamType' => 'Streams/chat',
'tool' => 'Safebox/tool/Safebots/suggest',
'active' => true,
),
);
Streams::create($communityId, $communityId, 'Safebots/bot', array(
'name' => 'Safebots/bot/suggest',
'title' => 'Inline suggestions',
'attributes' => Q::json_encode(array(
'Safebots/active' => true,
'Safebots/handlers' => $handlers,
)),
), array('skipAccess' => true));
// Related to the publisher's bots index with the uniform relation type.
Streams::relate(
$communityId,
$communityId, 'Safebots/bots',
'Safebots/bot',
$communityId, 'Safebots/bot/suggest',
array('skipAccess' => true, 'weight' => time())
);Safebots_Bot::seedDefaults($publisherId) is the idiomatic way to seed
the two default bots (Safebots/bot/suggest, Safebots/bot/greet)
under a publisher — it handles the index category creation, streams, and
relations in one call.
Safebots registers ONE hook, on the substrate event Streams/postMessages,
which fires once per batch of messages posted anywhere in the substrate.
The hook is in handlers/Safebots/after/Streams_postMessages.php and
calls Safebots_Bot::dispatch($streams, $posted, $skipAccess).
For each (publisherId, streamName) pair in the batch:
- Enumerate the publisher's active bots via
Safebots_Bot::activeBotsForPublisher($publisherId)— a singleStreams::relatedcall on theSafebots/botscategory. Memoized per dispatch so multiple messages under the same publisher don't re-query. - For each posted message under that stream, walk each bot's
Safebots/handlersmap:- Skip if the bot's
Safebots/activeis not true. - Skip if
handlers[message.type]is missing. - Skip if the handler's
activeis false. - Skip if
streamTypefilter doesn't match the affected stream's type. - Skip if
fromTypefilter (for relate-messages) doesn't match the counterpart stream's type. - Skip if the message was authored by the publisher itself, unless
the handler declared
allowSelfAuthored: true. This is the default loop-guard — under the composition model the publisher IS the bot, and a bot's own writes shouldn't fan back into its handlers.
- Skip if the bot's
- Surviving handlers dispatch fire-and-forget to Node via
Q_Utils::sendToNodewithQ/method: 'Safebots/invokeTool'. The Node handler loads the tool, runs it in the Safebox sandbox, and the tool's outputs flow back through normal substrate writes.
The dispatcher does not itself make LLM calls, fetch streams, or propose actions. Those all happen inside the tool, in the Safebox sandbox, where they're billed, cached, and governed by the standard Safebox machinery.
Previous iterations of this plugin registered per-event
handlersAfterEvent entries in plugin.json (e.g.
Streams/create/Streams/joined). That worked but required the plugin to
pre-declare every event any bot might care about — adding a new bot
meant editing plugin.json.
Subscribing to Streams/postMessages once and filtering in-memory by each
bot's Safebots/handlers attribute removes that coupling entirely.
Communities can author new bots reacting to new message types without
ever touching plugin configuration. The hot-path cost — early-return
when a publisher has zero active bots — is a single Streams::related
hit, cheap enough that every substrate message posting can pay it.
A bot is a configuration stream. Creating or updating a bot stream goes
through the normal Streams update pipeline, which — if the community has
a policy on Safebox/policies covering Streams/update on
Safebots/bot — triggers Actions.propose like any other stream edit.
Communities that want strict governance of their bot config set such a
policy; communities that are happy with direct edits leave it off.
The tool a handler references must be audited (per Safebox.md §15.4) — that's where the code-signing gate lives. A community can reference any audited tool in a handler; they cannot reference an un-audited one because the Safebox sandbox rejects loading unaudited tool code.
When a community relates a bot stream to its Safebots/bots category,
that act of enabling IS the standing governance decision. The bot's
tools propose Safebox/action streams whenever they write, and
Safebox/policy/Safebots/activated — a voting policy with
approvalThreshold: 0 — auto-approves those proposals synchronously
(see Safebox/classes/Safebox/Action.php → onActionCreated, the
threshold=0 branch).
The "1 of 1 by the community" pattern is expressed exactly: the community pre-casts its approval by activating the bot; every subsequent firing of the bot's tools rides on that pre-cast approval. No per-firing vote, no per-firing prompt to the community, no special bot-exempt-from-governance mode. The bot's writes look to Safebox exactly like any other proposed action — just with a policy that says yes immediately.
Communities wanting stricter governance on specific bot behaviors author
a custom policy with a higher threshold and register it under their
Safebox/policies for the action types in question. The standard
Safebox policy-evaluation logic handles the rest; the bot's writes block
on votes the way a workflow task's would.
Safebots::ensureActivationPolicy($publisherId, $actionTypes) creates
this policy and registers it for the default action types the seeded
bots' tools produce. The installer calls it for the hosting community;
other communities onboarding Safebots call it under their own publisher
with Safebots::defaultActionTypes() (extensible for community-authored
bots that propose additional action types).
In Telegram, when you add an inline bot to a chat, that bot is available to anyone typing in the chat. The chat's administrator decided to enable it; the participants then use it. Safebots works the same way.
For a given event on a stream:
- The stream's publisher is the user whose behaviors are consulted.
- The event's subject (the stream, the message, the relation) is the input the behavior's tool operates on.
- The event's triggerer (whoever posted the message, whoever proposed the action) is captured as context for the tool but isn't the one whose behaviors run.
A user posting in a community's chat does not trigger their own behaviors against the community's stream; they trigger the community's behaviors. That's the right model — it's the community's chat, the community decides what bot-assistance is available, participants get the benefit.
A community user and an individual user can both publish bot streams. Alice publishes behaviors that fire on Alice's streams (her personal timeline, her DMs, her notes). Her community publishes behaviors that fire on the community's streams. They don't interfere. If Alice is participating in the community's chat, only the community's behaviors fire — because the chat's publisher is the community.
If a chat has a single publisher (the common case), only that publisher's behaviors fire. A chat with co-publishers (not currently a Qbix primitive, but if it were) would invoke each publisher's behaviors — each under its own billing context.
The more common cross-community scenario is content flow: a behavior on Community A's chat fetches data from Community B's shared streams to inform a suggestion. This composes cleanly with the attribution strip (§6) — source = B (data origin), agent = A (who produced the message), strip shows "from @B via @A."
Every augmented message carries attribution metadata in
instructions['Safebots/via']:
{
"source": { "userId": "...", "displayName": "..." },
"agent": { "userId": "...", "displayName": "..." },
"promptRef": "...",
"artifactKind": null
}- source is the community whose data / policies / knowledge shaped the content. Usually this equals the stream's publisher.
- agent is the community (or user) that produced the augmentation — invoked the LLM, ran the tool, authored the output. Usually this also equals the stream's publisher.
Both are recorded so history stays reconstructable; which parts render on screen are decided at render time by the four-case collapse rule.
Render time has two userIds available from the message itself:
author=message.byUserId— whoever posted.publisher=message.publisherId— the stream's owner.
Combined with source and agent from the instructions:
| author ?= agent | source ?= publisher | Render |
|---|---|---|
| equal | equal | no strip (fully redundant) |
| equal | different | from @source |
| different | equal | via @agent |
| different | different | from @source via @agent |
When a community's own behavior generates a message that the community posts
under its own identity, all four are the same userId and the strip hides —
the message looks like any other community-authored post. When a user
accepts an inline suggestion and sends it, author is the human but agent
is the community, so the strip shows "via @community" to indicate AI
involvement. Cross-community content flow naturally produces the full
from @source via @agent rendering.
The collapse rule lives in web/js/tools/safebots/chat.js
_renderViaStrip(). It reads the Safebots/via instructions blob from each
message and applies the rule to decide what (if anything) to render above
the message content.
The Safebots/suggest endpoint powers Telegram-style inline completions in
any chat whose publisher has a Safebots/bot/suggest behavior active.
POST /Safebots/suggest
publisherId chat stream's publisher (the community)
streamName chat stream's name
composerText what the user has typed so far (may be empty)
maxSuggestions upper bound on returned suggestions (1–5, default 3)
The PHP handler does auth, access, request shaping, and dispatches to Node
via sendToNode(reply: true) — the synchronous round-trip primitive. No
LLM work happens in PHP. All heavy lifting — prompt assembly, Protocol.LLM
call, billing, cache consultation — happens in Node, inside the Safebox
sandbox, as the tool code referenced by the community's suggest behavior.
Safebots/suggest(publisherId, streamName, composerText, maxSuggestions):
requester = Users::loggedInUser(true) # auth gate
stream = Streams_Stream::fetch(null, publisherId, name) # access check
stream->testReadLevel('content') or throw
behavior = Safebots_Bot::find(publisherId, 'suggest-request')
if !behavior or !behavior.active:
return [] # no suggestions available
source = { userId: publisherId, displayName: Safebots::displayNameOf(publisherId) }
agent = source # community is its own bot
goalPrompt = Safebots_Goal::buildSystemPrompt(publisherId, streamName)
composerText = truncate(composerText, maxComposerLength)
nodeResult = sendToNode({
'Q/method': 'Safebots/invokeTool',
'behavior': behavior.name,
'tool': behavior.getAttribute('Safebots/tool'),
'publisherId': publisherId,
'streamName': streamName,
'composerText': composerText,
'maxSuggestions': maxSuggestions,
'goalPrompt': goalPrompt,
'communityId': publisherId,
'requesterUserId': requester.id
}, {reply: true, timeout: 10000})
# Node returns { suggestions: [{ text, artifactKind }, ...] }
# PHP attaches source/agent before slotting
suggestions = []
for item in nodeResult.suggestions:
suggestions.append({
text: item.text,
source: source,
agent: agent,
promptRef: null,
artifactKind: item.artifactKind
})
Q_Response::setSlot('suggestions', suggestions)
server.addMethod('Safebots/invokeTool', function (parsed, req, res, ctx) {
// Load the referenced Safebox/tool, verify sha256
// Build sandbox API, invoke tool with event payload
// Tool returns suggestions array → res.send({ slots: { suggestions: [...] } })
});The Node handler uses the reply-mode IPC form — sendToNode(reply: true)
from PHP blocks until Node calls res.send(...), returning the decoded JSON
body synchronously. This is the right primitive for suggestion generation:
users expect a 1–2 second response, and async-job patterns (webhooks, polling)
are wrong-shaped for interactive UI.
The Safebots/safebots/chat tool (in web/js/tools/safebots/chat.js) is a
Streams/chat extension that adds a "Suggest" menu item and a hidden
suggestions panel. Typing in the composer, or tapping "Suggest," calls the
endpoint; tapping a suggestion fills the composer with its text and records
the attribution for the next beforePost hook to attach to the outgoing
message. See §14 for the full chat-extension reference.
A goal stream is a reusable system-prompt template, with placeholders for interpolation at chat-attachment time. Goals are how Safebots chats know what they're trying to accomplish beyond freeform conversation.
Platform-default goals ship in the install seed script, published by
Users::communityId() (the hosting app's community). Communities that want
to customize a goal create their own version under their own publisher:
Users::communityId() / Safebots/goal/generate/capability ← platform default
{acmeCommunityId} / Safebots/goal/generate/capability ← Acme's customization
Safebots_Goal::findGoalStream($goalStreamName, $communityId) tries the
community's own version first, falls back to the platform default. This
lets any community override any goal without touching the install script.
Safebots/fields field-name: value pairs for template interpolation
Safebots/context context blob — urls, background info, etc.
Safebots/pattern "fields" | "artifact" | "process" | "codeGen"
Safebots/model override model for this goal
Safebots_Goal::buildSystemPrompt($chatPublisherId, $chatStreamName):
- Follows the chat stream's
Safebots/goalrelation to find its goal stream (a chat has at most one goal at a time). - Reads the goal's content (the template).
- Merges
Safebots/fields+Safebots/context.urlsinto a single interpolation dict. - Runs
Q::interpolate($content, $dict)and returns the resulting system prompt.
If the chat has no goal relation, returns an empty string — callers (like
Safebots/suggest) append context for their own use.
A dialog is a time-boxed conversation instance focused on producing a specific artifact or collecting a specific set of fields. Dialogs are created from templates that reference a goal.
Safebots_Dialog::create($communityId, $dialogType, $draftName, $goalFields);$communityId: the community publishing this dialog (also the chat's publisher, so behaviors fire against this community).$dialogType:"capability","tool","workflow", etc. — selects the goal to relate.$draftName: slug used in the stream name.$goalFields: field values for interpolation.
Creates a Safebots/dialog/{draftName} stream under the community, sets
initial attributes, relates it to the platform (or community-fork) goal.
The community itself is implicitly the publisher — behaviors attached to
the community run on this dialog the same way they'd run on any other
chat the community publishes.
Marks a dialog dialogStatus = done, records completion time, and posts a
Safebots/dialog/complete message to the dialog stream. Because the dialog
is published by the community, the completion message is authored by the
community under its own identity — no bot user needed.
Chats can carry a Safebots/greeting attribute — a structured value the
chat extension renders as a sticky strip above the message stream. It's
the chat's "storefront": always-present content every viewer sees
identically, like a pinned message, updateable over time by whatever
mechanism the community has configured.
Earlier iterations of this plugin used a reactive pattern: listen for
Streams/joined, check an idempotency flag, post a greeting as the
first non-publisher user joins. That worked but conflated display with
authorship. Users who never formally "joined" (arrived via a link, read
over a shoulder, viewed an export) would never see a greeting. Races on
simultaneous first joins produced duplicate posts. The flag itself was
state that wanted to live in display code, not in the substrate.
The storefront model separates cleanly. What is displayed is the
current value of Safebots/greeting, pure function of the attribute.
When that value is authored is a bot's concern — the greet bot
declares which events trigger a refresh and writes the attribute when
they fire. The chat extension reads the attribute on render and any
time the stream refreshes. No idempotency bookkeeping, no per-user
state, no join-dependent authorship.
Safebots/greeting: {
text: "Welcome. Feel free to introduce yourself...",
options: [
{ label: "What can I do here?", payload: "help" },
{ label: "Start a conversation", payload: "open" }
],
updatedAt: 1714000000,
authoredBy: "{communityId}"
}
text is the greeting prose; options is an array of tappable buttons
the chat extension renders below it. Tapping an option fills the
composer with the option's label so the user can edit and post normally —
the user still authors their message, the storefront merely suggests
starting points.
updatedAt and authoredBy are metadata for audit; the chat extension
doesn't use them directly but they're available for UI that wants to
show "storefront updated by @community 3 days ago."
Safebots/bot/greet is the canonical greet bot that
Safebots_Bot::seedDefaults installs. Its handlers map declares:
Safebots/handlers: {
"Streams/created": {
"streamType": "Streams/chat",
"tool": "Safebox/tool/Safebots/greet/Streams_chat_created",
"active": true
},
"Streams/relatedTo": {
"streamType": "Streams/chat",
"fromType": "Safebots/goal",
"tool": "Safebox/tool/Safebots/greet/goal_attached",
"active": true
}
}
The first handler fires when a chat stream is created — the tool writes
the initial Safebots/greeting attribute so the storefront is present
from birth. The second handler fires when a goal is attached to the
chat — the tool regenerates the greeting to reflect the new goal
context. Communities can extend this bot with further handlers (e.g.
Safebox/action/approved when the community promotes a new version, to
refresh the storefront's "currently featured" message) without editing
any plugin code.
Until Safebox/tool/Safebots/greet/* tool streams are authored and
audited, classes/Safebots.js contains an inline compatibility shim
(_runGreetShim) that reproduces the intended tool behavior for the
two default handler keys. The shim composes a deterministic template
greeting and writes it via the internal Safebots/internal/setAttribute
endpoint. Replacement with the real tool dispatch is mechanical once
audited tool streams exist.
web/js/tools/safebots/chat.js::_renderGreeting reads
chatTool.state.stream.getAttribute('Safebots/greeting'), renders the
text and options into a .Safebots_greeting element, and prepends it to
the chat's message container. The element is sticky so it stays visible
as users scroll through recent messages.
_hookGreetingRefresh subscribes to the existing chatTool.state.onRefresh
event (the same hook that re-hydrates the chat tool on attribute
changes) with a distinct tool-id suffix so the greeting re-renders
whenever the attribute changes — e.g. when the greet bot refreshes the
storefront after a goal is attached.
CSS for the storefront strip lives in web/css/Safebots.css under the
.Safebots_greeting selector family.
Streams::relate() is the universal emission primitive. Sending an email,
posting a tweet, emitting an AI summary — all modeled as relating the
content stream to a channel stream. The Streams/relatedTo message IS the
record, delivered via the existing notification cascade (see Safebox.md §9
and the Streams_Stream::notifyParticipants pipeline).
{communityId} / Streams/incoming/imap/{emailAddress}
{communityId} / Streams/outgoing/smtp/{emailAddress}
{communityId} / Streams/incoming/telegram/{chatId}
{communityId} / Streams/outgoing/telegram/{chatId}
{communityId} / Streams/incoming/com.twitter/{handle}
{communityId} / Streams/outgoing/com.twitter/{handle}
All channel streams for a customer relate to the customer's
Assets/holding/{customerId} stream.
Incoming emails are materialized as Safebox/job/email/{provider}/{messageId}
streams published by the community (see Safebox.md §3 for the attachment
sub-stream layout). Once created, the community's Safebots/bot/*
configs can trigger on stream-create events matching Safebox/job to
route incoming mail through reviewers, route to tickets, generate
auto-replies, etc. — all as normal behavior dispatch.
An artifact (a draft proposal, a generated image, a spec document) has a
lifetime within a chat: [firstRef, lastRef]. Messages in the chat can
reference the artifact via instructions['Safebots/artifact'] — the
refcount is the count of messages whose instructions cite the artifact.
When the refcount drops to zero AND the artifact isn't related-out to any
persistent category (publication, archive, external channel), it's eligible
for pruning. The safebox_artifact_ref table (Safebox.md §19) tracks
refcounts.
Artifacts that are promoted (via votes — see §13) get a durable relation to the community's publication category, which holds a refcount above zero permanently until explicit demotion.
Multiple versions of an artifact (forks of a draft, alternative proposals)
can coexist. Promotion from "candidate" to "published" is governed by
Users_Vote rows on the candidate artifact streams, tallied by a community
policy.
- Behavior produces artifact version V1 → behavior's tool calls
Actions.propose('Streams/create', {...})withSafebots/artifactas the stream type. - Chat message references V1 via
instructions['Safebots/artifact']. - If V1 is a candidate for publication, a parallel
Actions.propose('Streams/relate', {...})with relation typeSafebots/candidatefrom the publication category to V1. - Community members vote on V1 via
Users_Voterows. - When a vote causes V1 to become the top candidate, a proposal to
promote it —
Actions.propose('Streams/relate', {...})with relation typeSafebots/promoted— is evaluated. The community's promotion policy checks the current vote tally; if V1 is the winner, the promotion relation commits. - The previous promoted relation (if any) is superseded by a matching
Streams/unrelateproposal, evaluated under the same policy.
Some actions cannot be unwound — an email is sent, a tweet is posted, a payment is made. Those live in Safebox as executor capabilities with appropriately strict policies (often higher thresholds than content governance). Chat-internal operations (add a message, relate a stream, update an attribute) are reversible and can run under looser policies.
The goal-directed chat extension is a Streams/chat extension that reads the chat's goal relation, interpolates the system prompt, and hooks message rendering + posting.
Prerequisite declaration. The tool declares ["Streams/chat"] as
prerequisite in Q.Tool.define, so Qbix guarantees the chat tool loads
first. The tool reads chatTool.state.stream and defers via
onRefresh.add(...) if the stream isn't yet loaded.
onMessageRender and beforePost hooks are tool-keyed. Multiple chat
extensions can coexist on the same element; each registers its handler
with its own tool key (.set(handler, tool)) and they don't collide. The
attribution-strip extension (Safebots/safebots/chat) and the
goal-directed extension (Safebots/goal/chat) run on the same chat and
read different instructions keys.
Posting as the publisher vs as the user. Autonomous bot posts — the
greeting, goal-respond callbacks — use the chat publisher as byUserId.
Suggestion-accepted posts use the human user as byUserId and rely on
the attribution strip to show AI involvement. The behavior's governance
mode determines which path is taken: autonomous = community posts
directly; propose = proposal queues for review.
beforePost attribution tagging. When the user accepts an inline
suggestion, the chat extension stashes the attribution metadata on
tool._pendingVia. The beforePost hook reads this and adds
instructions['Safebots/via'] to the outgoing message. The attribution
travels with the message forever.
The chat extension calls Q.req('Safebots/suggest', ['suggestions'], ...)
when the user taps the menu item or triggers the trigger character. Results
come back as data.slots.suggestions, each entry carrying
{text, source, agent, promptRef, artifactKind}. Clicking a suggestion
fills the composer and stashes the attribution.
Communities can grow their own capabilities and tools by having Safebots
chats guide the design. A dialog of type capability or tool uses a
platform goal whose prompt walks the community through spec, then hands
the spec to a generator tool that produces the code and proposes
Actions.propose('Streams/create', ...) with the appropriate stream type.
The resulting stream carries Safebox/pendingCode (per Safebox.md §9) —
the code file is written to disk at execute time and sha256-verified.
Safebox/approved remains false until a Streams/update proposal (the
audit) sets it true, gated by the community's Safebox/tool or
Safebox/capability update policy — typically requiring M-of-N approvals
from a Safebox/auditors-style label (per Safebox.md §15.4).
This is exactly the same flow community policies themselves go through (§15.4 of Safebox.md). A community that wants new bot behavior writes the tool in chat, proposes its creation, proposes its audit, and once both proposals have crossed their governance thresholds the behavior is available to reference.
class Safebots_Bot {
/**
* Enumerate all active bots on a publisher.
* One Streams::related call, memoized per-request by the dispatcher
* so multiple messages under the same publisher in a batch don't
* re-query. Filters bots whose Safebots/active attribute is boolean true.
*/
static function activeBotsForPublisher($publisherId);
/**
* Universal dispatch entry. Called by the Streams/postMessages
* after-hook with the full posted batch. For each (publisherId,
* streamName) pair, enumerates active bots and fires every matching
* handler. Exceptions are caught and logged — a bot's failure must
* never take down the calling write.
*
* @param array $streams [$publisherId][$streamName] => Streams_Stream
* @param array $posted [$publisherId][$streamName] => [Streams_Message,...]
* @param bool $skipAccess
*/
static function dispatch($streams, $posted, $skipAccess = false);
/**
* Seed the default bots for a publisher. Creates Safebots/bots
* index category, Safebots/bot/suggest, Safebots/bot/greet, and
* relates each to the index with relation type Safebots/bot.
* Idempotent.
*/
static function seedDefaults($publisherId);
}Matching filter logic inside dispatch:
for each posted message M on (publisherId P, stream S):
for each active bot B on P:
H = B.Safebots/handlers[M.type]
if !H or !H.active: skip
if H.streamType and H.streamType != S.type: skip
if H.fromType and counterpartType(M) != H.fromType: skip
if M.byUserId == P and !H.allowSelfAuthored: skip
sendToNode('Safebots/invokeTool', { tool: H.tool, ...context })
static function ensureActivationPolicy($publisherId, array $actionTypes);Creates (publisherId, Safebox/policy, Safebots/activated) as a voting
policy with Safebox/approvalThreshold: 0 and Safebox/approved: "true",
then relates it to the publisher's Safebox/policies category with one
relation per action type. Idempotent: stream-creation skips if the policy
exists, relations catch duplicate-key exceptions quietly.
This is how the "activation is the governance" claim gets operationalized.
Action.propose's onActionCreated evaluates policies matched by relation
type = action type; our policy with threshold 0 auto-approves immediately.
Called from:
scripts/Safebots/0.1-Streams.mysql.phpat install (for the hosting community).- Any community onboarding Safebots that wants its bots' tools to
auto-approve — typically when
Safebots_Bot::seedDefaultsis called under a new publisher.
static function defaultActionTypes(): array;Returns the set of Safebox action types the seeded default bots' tools
produce. Currently ['Streams/setAttribute', 'Streams/message', 'Streams/relate']. Communities authoring custom bots extend this with
the types their bots' tools will propose, and call
ensureActivationPolicy under their own publisher.
Follows the chat's Safebots/goal relation, interpolates the template,
returns the resulting prompt.
static function buildSystemPrompt($chatPublisherId, $chatStreamName) {
// Use platform-default community as asUserId so cross-community reads
// of platform goals succeed without community-specific grants.
$botId = Users::communityId();
list($relations, $goalStreams) = Streams::related(
$botId, $chatPublisherId, $chatStreamName,
false, // what THIS stream relates TO
array('type' => 'Safebots/goal', 'limit' => 1, 'skipAccess' => true)
);
if (!$goalStreams) return '';
$goal = reset($goalStreams);
$fields = $goal->getAttribute('Safebots/fields', array());
$context = $goal->getAttribute('Safebots/context', array());
$urls = Q::ifset($context, 'urls', array());
$all = array_merge((array)$fields, (array)$urls);
return $all
? Q::interpolate($goal->content, $all)
: $goal->content;
}skipAccess => true is legitimate here — it's an infrastructure read from
the community's own code against a stream the community owns or a
platform-default goal stream. It is NOT a governance bypass for bot writes
(which go through Actions.propose and ride the activation policy).
static function create($communityId, $dialogType, $draftName, $goalFields)Creates the dialog stream under $communityId, relates to goal, sets
attributes. Idempotent.
static function complete($publisherId, $streamName, array $result)Marks status done, posts completion message under the community's identity (the publisher of the dialog stream).
handlers/Safebots/suggest/response.php— thin dispatcher (§7)handlers/Safebots/dialog/response.php— create dialog from client requesthandlers/Safebots/dialog/complete.php— mark dialog donehandlers/Safebots/goal/respond/response.php— user posted chat → fire LLMhandlers/Safebots/goal/message/response.php— Node callback with LLM outputhandlers/Safebots/after/Streams_postMessages.php— universal dispatcher hook that routes every substrate message batch throughSafebots_Bot::dispatchhandlers/Safebots/after/Q_Plugin_install.php— installer entry point, triggers seed script + activation policyhandlers/Safebots/internal/setAttribute/response.php— node-internal compatibility endpoint used byclasses/Safebots.jsshims to write attributes under the publisher's identity (e.g. the storefront greeting). Retirable once real tool streams underSafebox/tool/Safebots/*ship.
Resolves a userId to a display name via Streams_Avatar::fetch, with a
defensive fallback to the raw userId on lookup failure. Used by the
attribution builder.
static function displayNameOf($userId) {
if (!$userId) return '';
try {
if (class_exists('Streams_Avatar')) {
$avatar = Streams_Avatar::fetch($userId, $userId, false);
if ($avatar && method_exists($avatar, 'displayName')) {
$dn = $avatar->displayName(array('short' => true));
if ($dn) return $dn;
}
}
} catch (Exception $e) {
Q::log('Safebots::displayNameOf: avatar lookup failed: '
. $e->getMessage());
}
return $userId;
}Registers all Safebots tools via Q.Tool.define and maintains the
Q.Safebots.Chat.extensions array listing chat extensions that activate
automatically on any Streams/chat tool.
Q.Tool.define({
"Safebots/goal/chat": { js: "...", css: "...", text: [...] },
"Safebots/safebots/chat": { js: "...", css: "...", text: [...] },
"Safebots/stream/tree": { js: "...", css: "...", text: [...] }
});
Q.Safebots.Chat = {
extensions: ['Safebots/goal/chat', 'Safebots/safebots/chat']
};Platform code that renders chats iterates this registry and activates each extension on the chat element, so adding a new extension doesn't require touching chat-rendering call sites.
The Node side listens for IPC dispatched by PHP's Q_Utils::sendToNode.
Methods are registered against the IPC server returned by Q.listen():
var server = Q.listen();
server.addMethod('Safebots/goal/respond', function (parsed) {
// fire-and-forget; Protocol.LLM then sendToPHP back
});
server.addMethod('Safebots/invokeTool', function (parsed, req, res, ctx) {
// reply-mode: Safebox sandbox runs the behavior's tool, res.send(result)
});Note on IPC APIs. Q.Socket.onEvent(...) is the browser-side socket
API; it does not route sendToNode traffic. Node IPC goes through the HTTP
dispatcher registered by Q.listen, which exposes server.addMethod(name, handler). Use addMethod. Reply-mode handlers take the four-arg form
(parsed, req, res, ctx) and call res.send(JSON.stringify({slots: {...}})).
Qbix merges every installed plugin's classes/ directory into the Node
module resolution path. Use bare names for cross-plugin imports:
var Protocol = Q.require('Safebox/Protocol'); // ← bare name
// NOT:
// var Protocol = require('./Safebox/Protocol'); // ← fails with MODULE_NOT_FOUNDOn PHP, omitting the first arg of Streams_Stream::fetch() resolves to
Users::loggedInUser() — there's a session. On Node, there is no PHP
session; null resolves to the public viewer. Any Node-side fetch that
needs authoritative access must pass an explicit identity. Callbacks to
PHP carry the identity explicitly in their payload for exactly this reason.
Protocol.LLM substitutes credential placeholders at call time. Credentials
live in Safebox/credential/{keyName} streams published by the community
(see Safebox.md §13). Use the {{credentials:keyName}} placeholder in
config:
var apiKey = Q.Config.get(['Safebots', 'llmApiKey'], '{{credentials:openai_api_key}}');
Protocol.LLM({ model: model, apiKey: apiKey, messages: messages, ... });Safebots UI lives inside Q/columns where provided by the host app. The chat
is one column; overlays (artifact detail, history, branching) open as
adjacent columns, not modals. The chat extension's _openArtifactChat
uses Q.invoke to open a new column containing a chat scoped to the
artifact stream, with the same extensions active, so iteration recurses.
Dialog and artifact streams render with status badges reflecting
Safebots/dialogStatus or Safebots/status attributes:
drafting— grayspecReady/candidate— bluedone/promoted— greenrejected/abandoned— red
The attribution strip is a small, low-weight pill above the message content. Font is 11px, background is a low-opacity gray, text color is the secondary text color — intentionally unobtrusive. Attribution is inspectable but shouldn't pull attention away from message content.
function Safebots_after_Q_Plugin_install($params)
{
if (!isset($params['plugin']) || $params['plugin'] !== 'Safebots') return;
// Seed default bots, platform goals, and the Safebox activation policy
// under the hosting community. No bot-user provisioning — the community
// is its own bot. No access grants — the community has max access on
// its own streams.
require_once SAFEBOTS_PLUGIN_DIR . DS . 'scripts' . DS . 'Safebots'
. DS . '0.1-Streams.mysql.php';
}Resolves the hosting app's community once at the top:
$botId = Users::communityId();Creates, all under $botId:
Safebots/goals— the platform goal index category.- Platform-default goal streams (
Safebots/goal/collect/profile,Safebots/goal/generate/capability, etc.) related to the goals index. Safebots/bots— the bots index category.Safebots/bot/suggest— default inline-suggest bot with a handler forSafebots/suggest/request.Safebots/bot/greet— default storefront greeting bot with handlers forStreams/createdandStreams/relatedTo(goal attachment).Safebox/policies— the Safebox policy index for this community.Safebox/policy/Safebots/activated— the voting policy withapprovalThreshold: 0that auto-approves actions proposed by the community's activated bot tools.- Relations from the activation policy to the policy index — one per
default action type returned by
Safebots::defaultActionTypes():Streams/setAttribute,Streams/message,Streams/relate.
All creates are idempotent via Streams_Stream::fetch(..., throwIfMissing: false) + skip-if-present; all relates catch duplicate-key exceptions
quietly. Re-running the seed is safe.
Other communities that want to opt into Safebots run an analogous seed under their own publisher — either via an admin UI action or directly:
Safebots_Bot::seedDefaults($theirCommunityId);
Safebots::ensureActivationPolicy(
$theirCommunityId,
Safebots::defaultActionTypes()
);Communities authoring custom bots that propose additional action types
call ensureActivationPolicy again with those types appended; the policy
stream is already in place, only new relations get added.
- Does not create a bot user (there isn't one).
- Does not write access rows (the community has its own streams covered by publisher privilege).
- Does not rename anything from prior versions. Deployments from before
the composition-model conversion that had streams published under a
literal
'Safebox'user string or a provisioned'Safebots'bot user need a manual re-seed under the hosting community — the target community id depends on the deployment and can't be inferred by a migration script. - Does not author
Safebox/tool/Safebots/*tool streams. Those need to flow through Safebox's capability/tool generation pipeline (Safebox.md §15.4). Until they exist,classes/Safebots.jscarries inline compatibility shims for the default tool paths; the shims are tagged with replacement notes.
{
"Safebots": {
"llmApiKey": "{{credentials:openai_api_key}}",
"defaultModel": "gpt-4o-mini",
"maxUserMessageLength": 4000,
"maxComposerLength": 2000,
"suggestTimeoutMs": 10000,
"models": {
"fields": "gpt-4o-mini",
"artifact": "gpt-4o",
"process": "gpt-4o",
"codeGen": "claude-sonnet-4-6"
}
}
}Notes:
- The platform bot user field (
Safebots/safebotUserId) that older drafts documented has been removed. There is no bot user. - The bot-access migration keys (
Safebots/defaultBotWriteLevel,Safebots/botAccessiblePrefixes) are likewise removed — there is no install-time access provisioning. - Credentials use the
{{credentials:name}}syntax and resolve againstSafebox/credential/{name}streams (Safebox.md §13).
End of Safebots Plugin Architecture Reference. Version: 2026-04 · companion to Safebox.md