Skip to content

SafeBots/Safebots

Repository files navigation

Safebots Plugin — Architecture & Implementation Reference

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.


Table of Contents

  1. Overview & Philosophy
  2. Plugin File Structure
  3. Stream Types
  4. Bots — The Core Abstraction
  5. The Publisher-Augments Model
  6. Attribution — from @source via @agent
  7. Safebots/suggest — Inline Suggestions
  8. Safebots/goal — Goal Streams
  9. Safebots/dialog — Dialog Streams
  10. Storefront Greeting
  11. Relations as Emissions — Channels
  12. Artifact Lifetime & Refcount Model
  13. Voting, Versions & Promotion
  14. Safebots/goal/chat — JS Chat Extension
  15. Capability & Tool Generation from Chat
  16. PHP Implementation Guide
  17. JavaScript Implementation Guide
  18. Node.js Implementation
  19. UI Reference
  20. Install & Seed
  21. Appendix: Configuration Reference

1. Overview & Philosophy

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.

The core insight — composition, not inheritance

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.

The publisher-augments model

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.

What Safebots is, in two paragraphs

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.

Bots as streams — the substrate does the work

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_Access rows, 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.

How it composes with Safebox

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.

The composition at runtime

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.


2. Plugin File Structure

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.


3. Stream Types

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.

Safebots/bot attributes

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 index

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.


4. Bots — The Core Abstraction

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.

Minimal bot example

$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.

How dispatch works

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:

  1. Enumerate the publisher's active bots via Safebots_Bot::activeBotsForPublisher($publisherId) — a single Streams::related call on the Safebots/bots category. Memoized per dispatch so multiple messages under the same publisher don't re-query.
  2. For each posted message under that stream, walk each bot's Safebots/handlers map:
    • Skip if the bot's Safebots/active is not true.
    • Skip if handlers[message.type] is missing.
    • Skip if the handler's active is false.
    • Skip if streamType filter doesn't match the affected stream's type.
    • Skip if fromType filter (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.
  3. Surviving handlers dispatch fire-and-forget to Node via Q_Utils::sendToNode with Q/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.

Why there's no per-event registration

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.

Why bots aren't themselves specially governed

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.

Activation is the governance decision

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.phponActionCreated, 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).


5. The Publisher-Augments Model

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.

The dispatch rule

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.

Same user in two roles

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.

Cross-community content flow

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."


6. Attribution — from @source via @agent

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.

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.

Implementation

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.


7. Safebots/suggest — Inline Suggestions

The Safebots/suggest endpoint powers Telegram-style inline completions in any chat whose publisher has a Safebots/bot/suggest behavior active.

Request

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)

PHP handler: thin dispatcher

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)

Node handler: runs the tool

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.

Client — chat extension

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.


8. Safebots/goal — Goal Streams

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 vs community goals

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.

Goal attributes

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

Prompt interpolation

Safebots_Goal::buildSystemPrompt($chatPublisherId, $chatStreamName):

  1. Follows the chat stream's Safebots/goal relation to find its goal stream (a chat has at most one goal at a time).
  2. Reads the goal's content (the template).
  3. Merges Safebots/fields + Safebots/context.urls into a single interpolation dict.
  4. 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.


9. Safebots/dialog — Dialog Streams

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

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.

Safebots_Dialog::complete

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.


10. Storefront Greeting

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.

Why storefront, not reactive

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.

The Safebots/greeting attribute

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."

The greet bot

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.

The chat extension's storefront render

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.


11. Relations as Emissions — Channels

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).

Channel naming

{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.

Inbound email

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.


12. Artifact Lifetime & Refcount Model

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.


13. Voting, Versions & Promotion

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.

Proposal flow

  1. Behavior produces artifact version V1 → behavior's tool calls Actions.propose('Streams/create', {...}) with Safebots/artifact as the stream type.
  2. Chat message references V1 via instructions['Safebots/artifact'].
  3. If V1 is a candidate for publication, a parallel Actions.propose('Streams/relate', {...}) with relation type Safebots/candidate from the publication category to V1.
  4. Community members vote on V1 via Users_Vote rows.
  5. When a vote causes V1 to become the top candidate, a proposal to promote it — Actions.propose('Streams/relate', {...}) with relation type Safebots/promoted — is evaluated. The community's promotion policy checks the current vote tally; if V1 is the winner, the promotion relation commits.
  6. The previous promoted relation (if any) is superseded by a matching Streams/unrelate proposal, evaluated under the same policy.

Reversible vs irreversible

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.


14. Safebots/goal/chat — JS Chat Extension

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.

Key implementation notes

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.

Dispatch from the chat extension to Safebots/suggest

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.


15. Capability & Tool Generation from Chat

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.


16. PHP Implementation Guide

Safebots_Bot

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 })

Safebots::ensureActivationPolicy

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.php at install (for the hosting community).
  • Any community onboarding Safebots that wants its bots' tools to auto-approve — typically when Safebots_Bot::seedDefaults is called under a new publisher.

Safebots::defaultActionTypes

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.

Safebots_Goal::buildSystemPrompt

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).

Safebots_Dialog

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

  • handlers/Safebots/suggest/response.php — thin dispatcher (§7)
  • handlers/Safebots/dialog/response.php — create dialog from client request
  • handlers/Safebots/dialog/complete.php — mark dialog done
  • handlers/Safebots/goal/respond/response.php — user posted chat → fire LLM
  • handlers/Safebots/goal/message/response.php — Node callback with LLM output
  • handlers/Safebots/after/Streams_postMessages.php — universal dispatcher hook that routes every substrate message batch through Safebots_Bot::dispatch
  • handlers/Safebots/after/Q_Plugin_install.php — installer entry point, triggers seed script + activation policy
  • handlers/Safebots/internal/setAttribute/response.php — node-internal compatibility endpoint used by classes/Safebots.js shims to write attributes under the publisher's identity (e.g. the storefront greeting). Retirable once real tool streams under Safebox/tool/Safebots/* ship.

Safebots::displayNameOf($userId)

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;
}

17. JavaScript Implementation Guide

web/js/Safebots.js — Browser Bootstrap

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.


18. Node.js Implementation

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: {...}})).

Cross-plugin imports

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_FOUND

asUserId = null semantics

On 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.

API keys

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, ... });

19. UI Reference

Layout — Q/columns

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.

Status badges

Dialog and artifact streams render with status badges reflecting Safebots/dialogStatus or Safebots/status attributes:

  • drafting — gray
  • specReady / candidate — blue
  • done / promoted — green
  • rejected / abandoned — red

Via-strip styling

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.


20. Install & Seed

What the installer does

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';
}

What the seed script does

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 for Safebots/suggest/request.
  • Safebots/bot/greet — default storefront greeting bot with handlers for Streams/created and Streams/relatedTo (goal attachment).
  • Safebox/policies — the Safebox policy index for this community.
  • Safebox/policy/Safebots/activated — the voting policy with approvalThreshold: 0 that 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.

What the installer does NOT do

  • 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.js carries inline compatibility shims for the default tool paths; the shims are tagged with replacement notes.

21. Appendix: Configuration Reference

{
  "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 against Safebox/credential/{name} streams (Safebox.md §13).

End of Safebots Plugin Architecture Reference. Version: 2026-04 · companion to Safebox.md

About

Safebots stack to power Chats, Bots, Collaboration, Artifacts, Publishing and History

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors