Architecture¶
daimon is built on Anthropic Managed Agents. Managed Agents (MA) owns the agent, its sandbox, its skills and the session that runs a turn. daimon owns everything around that: the chat surfaces, the tenancy model, the config cascade, credentials, the credit ledger, and the pipeline that turns a chat message into a session and streams the result back into a thread.
This page is the map to read before the code. CONTRIBUTING.md has the dev
setup and the quality gates; configuration.md has every
setting; self-hosting.md has the deployment.
Discord, Slack, scheduler and MCP emit runtime.health every 30 seconds with
Anthropic response attempts, database pool use, event loop lag and active turns.
The Discord process also reports Discord 429 retries by route and longest retry wait.
When an opening Discord mention takes over three seconds to name or create its
thread, the bot replies in the parent channel with an opening notice, then edits
it with the thread link or retry guidance. The conversation stays in the thread.
The shape¶
flowchart TB
subgraph adapters["packages/adapters — one platform each"]
direction LR
Discord
Slack
Teams
MCP
Scheduler
CLI
end
subgraph core["packages/core — daimon.core"]
direction LR
turn["turn/<br>admit · bind_session · run_prepared_turn · driver"]
stores["stores/<br>Pydantic over the private ORM"]
defaults["defaults/<br>seed + reconcile"]
end
adapters --> core
core <--> ma["Anthropic Managed Agents<br>agents · environments · sessions · skills"]
core --> pg[("Postgres<br>tenants · identity · thread↔session<br>config · credentials · ledger")]
ma -. "tool calls back over HTTP" .-> MCP
The dotted edge is worth noticing early: the MCP adapter is both an inbound
adapter and the server the running agent calls its tools on. That is why a
turn started from Discord still reaches packages/adapters/mcp/ — the
sandbox makes an authenticated HTTP call back to it mid-turn.
Packages, and why the boundaries exist¶
| Package | Path | Owns |
|---|---|---|
daimon.core |
packages/core/daimon/core/ |
Schema and migrations, stores, MA helpers, the turn pipeline. Imports no adapter. |
daimon.adapters.discord |
packages/adapters/discord/ |
Discord I/O, rendering, permissions, slash commands. |
daimon.adapters.slack |
packages/adapters/slack/ |
Slack I/O, Block Kit rendering, per-user OAuth. |
daimon.adapters.teams |
packages/adapters/teams/ |
Teams HTTP ingress, Adaptive Card rendering, text commands and panels, file consent delivery, channel files through SharePoint. |
daimon.adapters.mcp |
packages/adapters/mcp/ |
The MCP server the agent calls, plus the OAuth, webhook and hub HTTP routes. |
daimon.adapters.scheduler |
packages/adapters/scheduler/ |
The routine poll loop. |
daimon.adapters.cli |
packages/adapters/cli/ |
The daimon admin binary. |
daimon.testing |
packages/testing/ |
Shared fixtures. |
mux |
packages/mux/ |
A separate namespace, not part of daimon. See mux.md. |
notebook_host, report_host |
apps/*/src/ |
Standalone services that talk to daimon over HTTP only. |
None of that is convention. Eight import-linter contracts in the root
pyproject.toml are the authority, run as uv run lint-imports in pre-commit
and CI:
| Contract | Forbids |
|---|---|
| Core must not import adapters | daimon.core → daimon.adapters |
| Adapters must not import each other | any of cli, mcp, discord, scheduler, slack, teams → another |
| ORM module is private to stores and defaults | anything but daimon.core.stores.** / daimon.core.defaults.** → daimon.core._models |
| Core must not import testing | daimon.core → daimon.testing |
| CLI admin commands and run must not import each other | daimon.adapters.cli.commands ⟂ daimon.adapters.cli.run |
| Mux must not import daimon | mux → daimon |
| notebook-host must not import daimon | notebook_host → daimon |
| report-host must not import daimon | report_host → daimon |
The first two make the platform surfaces replaceable: a change to how Slack
renders a table cannot reach Discord, and core can be exercised without any
chat client. The ORM contract is the one contributors trip over most — the
schema lives in packages/core/daimon/core/_models.py behind a leading
underscore, and everything outside daimon.core.stores and
daimon.core.defaults sees Pydantic models from
packages/core/daimon/core/stores/domain.py instead of SQLAlchemy rows. packages/core/tests/test_orm_import_contract.py fails when the
contract's enumerated module list drifts from the directory, because
import-linter cannot express "every sibling except _models".
The last three are trust boundaries rather than tidiness: a service that
cannot import daimon cannot hold the Anthropic key or a database
credential, whatever a future contributor is tempted to do inside it.
How a message becomes a turn¶
Discord, Slack and Teams run the same two-stage chokepoint in daimon.core.turn.
The staging is deliberate — neither stage returns a boolean, both raise typed
errors, so an adapter cannot forget a gate.
Stage one, admit() — packages/core/daimon/core/turn/admission.py. One
call does identity resolution, config resolution, and every pre-turn gate, and
returns a frozen Admission (account id, MA agent, MA environment, resolved
config). The order is load-bearing and documented as such in the module:
- Resolve the platform user to an
accountsrow, viaget_or_create_platform_principalinpackages/core/daimon/core/stores/identity.py. - Channel writers — a turn whose reply would land in a channel with
writers: none, a thread under one or (Discord) a channel in such a category raisesAdmissionDenied("writers_none"), admins included. It runs before the invoker gate, whose refusal would otherwise be posted there. If a Discord thread's category can't be resolved while any category has a rule, the turn is refused. - Invoker policy — the tenant's access policy (below) may restrict who can
start a turn. A refused user raises
AdmissionDenied("invoker_not_allowed")before the cascade, so they learn nothing about the tenant's configuration and no MA call is made. - External participant — a caller from another organisation (a Teams shared
channel's B2B direct connect participant or a guest,
admit(external=ExternalFinding(…))) raisesAdmissionDenied("external_participant")anywhere but a channel withreaders: ownor a thread in one, and in its setup thread too. Only a finding on positive evidence is stored onaccounts.is_external; one without holds that turn alone (turn_origins.is_external, which the MCP verifier reads) and keeps the stored role. An external account's stored role is alwaysuser, and it administers no channel.reauthorizechecks it again. - Resolve config through the cascade
thread → channel → tenant → deployment, inpackages/core/daimon/core/stores/scoped_config_read.py. The tiers are named byConfigTierinpackages/core/daimon/core/scope.py; the bottom one comes fromdefaults/config.yaml, see defaults.md. - Raise
MissingTurnConfigErrorif no agent or environment resolved — before any MA call, so a misconfigured tenant sees the config error rather than a billing one. - Resolve the agent and environment to live MA ids via
packages/core/daimon/core/ma_resolver.py, which self-heals by re-running defaults reconciliation when a tag no longer resolves, and rejects an agent whosearchived_atis set. - Balance gate —
tenant_balance.is_over_balance. - Monthly cap gate —
billing.is_over_cap. - Channel budget gate —
channel_budget.is_over_channel_budget, against the parent channel, or for a DM the channel it was moved from with/dm(dm_source_channel_id); skipped in an older DM or where the channel has no budget. A channel's own agent counts toward its home channel wherever an exempt caller runs it (AgentPermissions.budget_channel). The channel is carried onAdmission.channel_idso every debit for the turn is attributed to it. The window's first refusal also DMs the channel's admins through the adapter'sTurnDeps.budget_notifier(daimon.core.channel_budget_notice).
The policy, writers, external participant, balance, cap and channel
budget gates each raise AdmissionDenied with a reason literal, which
admission_refusal_text words in each platform's nouns. See
billing.md.
A Slack app_mention runs a turn only when its text contains the bot's own
<@U…> mention token, follow-ups in a thread included. Slack's docs say the
event fires only on a direct mention, but installs have reported it arriving
for thread replies that never mentioned the bot, so _handle_app_mention
checks the text itself, as Discord checks message.mentions. The bot's user
id comes from auth.test, cached per workspace. The check runs after dedup and
the token read and before the Slack Connect rejection, so an external sender
who never addressed the bot gets no notice. A dropped event is logged as
slack.event_dropped.no_explicit_mention; a failed auth.test drops the event
without an error reply.
An unmentioned reply in a Discord or Teams thread costs one cascade read of
thread_participation_scopes. In a followed thread it joins a quiet-timer
batch; once the thread goes quiet the shared gates in
packages/core/daimon/core/participation_gates.py run the hourly cap, balance,
cap and channel budget checks, then the metered classifier. A respond
verdict runs an ordinary turn through admit() as the burst's newest author,
with every notice withheld. Teams also counts a quote of the bot's message as
a mention.
Discord checks its per-guild in-flight limit before the optional process-wide
turn limit (DAIMON_DISCORD__MAX_CONCURRENT_TURNS). Guild mentions, unprompted
replies and DMs count against it; an excess requested turn gets a retry notice.
Unprompted replies follow their existing silent-refusal policy. Continuation
wakes retain their existing admission path. The limit is unset by default and
applies only to this Discord process.
Discord, Slack and Teams also limit simultaneous chat turns per tenant before admission.
daimon tenants turn-cap PLATFORM WORKSPACE_ID N stores a tenant override;
default clears it and restores the adapter's deployment setting (3 by default).
The check covers Discord mentions, thread participation and wizard submits,
Slack mentions, and Teams messages, wakes and thread participation. It does not limit MCP or routine turns.
A channel with writers: none hears nothing from the agent, not even a
refusal or an error. Each turn entry decides FIRST, before tenant liveness, provisioning or
any other read that can fail, whether the agent may post there:
protection_state (packages/core/daimon/core/turn/protection.py) returns
unprotected, protected or unknown, and never raises -- a policy that
doesn't parse, a database or pool failure, or a failed category lookup all
give unknown. The channel and its parent are checked first; a Discord
thread's uncached parent is fetched for its category only when a category
has a rule and the channel isn't already closed, and cached so
admission doesn't fetch it again. Anything but unprotected drops the turn
with only a log line, and the entries' error boundaries post only when the
state is unprotected. The entries are Discord on_message, organic thread
participation, wizard submit and continuation turns, and Slack
_handle_app_mention (with a second check in _orchestrate after it claims
the thread, and on its ephemeral shed notice), and Teams _handle and
thread participation (plus the refusals handle_message posts). The continuation dispatchers (Teams uses
core continuity/dispatch.py), which can post skip or responder-changed copy
outside any turn (from the wake poller or a credential submission), ask the
same decision right before each post and settle the row skipped without
posting when it isn't unprotected.
Tenant access policy — packages/core/daimon/core/access_policy.py. One
TenantAccessPolicy per tenant, stored as JSON in tenant_access_policies
(packages/core/daimon/core/stores/access_policy.py). A tenant with no row
gets the open default, so nothing changes until a policy is written; a present
row that is not a valid policy object (JSON null included) raises
AccessPolicyUnreadable and callers refuse rather than fall open. Unknown
fields are rejected too, so rolling back past a release that added a field
locks the tenant out until the row is rewritten.
Who counts as an admin differs by path. admit() trusts only the live role the
adapter passes; no role means non-admin. The MCP turn tools (ask,
start_turn, continue_turn, on the hub and per agent, plus billed media)
and routine fires have no live platform role, so they use the account's
stored role (and role ids, for channel admins), refreshed on every chat turn. The operator path
(platform_user_id unset: CLI and internal tokens) is not a platform member
and skips the policy, as it skips billing. An account marked external is
never an admin or a channel admin on any path: admission stores no group ids
for it, the MCP verifier reads the mark from the row on every request, and
the identity middleware refuses it the setup, channel setup, credential and
direct-message tools (middleware/external_participants.py). Ids are the
platform's own (Discord snowflakes, Slack ids, Teams Entra object and
conversation ids):
| Field | Empty means | Enforced by |
|---|---|---|
invoker_user_ids |
anyone may start a turn; admins always may | admit(), the MCP turn tools (_admit in tools/_ctx.py), routine fires |
channel_rules, category_rules |
every channel open | authorize(), asked by admit() (every path: mention, follow-up, wizard submit, continuation) and reauthorize, every Discord, Slack and Teams write tool (require_channel_writable in packages/adapters/mcp/daimon/adapters/mcp/tools/_channel_policy.py), the channel read tools via ChannelReadPolicy, the session transcript tools via tools/_session_access.py, the routine save and fire checks, every channel default bind, and visibility in the agent, skill, routine, routing and handoff tools, the hub, the setup panel and /memory |
agent_rules |
every agent runs wherever the cascade sends it | admit() after the agent is retrieved (every turn path, DM included), the MCP and hub turn tools, hand_off_task via decide_handoff, fork_agent, and routines at save (_check_agent_pin) and at every fire (scheduler) |
dm_memory_read_only (default false) |
DM turns get writable memory | admit(is_dm=True) sets Admission.memory_read_only |
Permissions defines the rules and every limit they set. A row an older build wrote (protected, sealed and isolated channel lists and channel pins) reads as the same rules; writes store rules.
writers: none covers threads under the channel and, on Discord, channels in
such a category; it applies to admins too, and runs after the caller's own
permission check so it never reveals a channel the caller cannot see. A
channel whose readers are inside or own is readable only from a turn
inside it (the channel or a thread under it): the read must pass the
origin_context_id from that turn's controls, and a missing, expired or
foreign origin counts as outside. The origin must also belong to the agent
the token executes as (agent_id, or chat_agent_id for ordinary chat); a
token bound to neither can't claim one. A single thread can take
readers: inside on its own: a Discord thread by its id, a Slack thread as
channel_id:thread_ts. Such a thread, its messages and (on Discord) its name
are withheld from outside turns: in read_thread and get_message, in
channel history (a Slack thread's root and broadcast replies; Discord's
thread-created notice, whose text is the name), in the channel context a Slack
top-level mention starts with, in list_threads and in search. Once any
readers are limited, search reports only the hits it shows as its total,
scoped or not, on both platforms, and Discord hints at more results only when
a full page of visible hits came back, so neither the count nor the hint can
reveal hidden matches. A turn inside a limited channel or thread gets
read-only memory. An origin is any active one of the same account and
responder, not only the current turn's: a member who copies an origin id out
of such a turn can read that channel from elsewhere until it expires --
someone who could read it anyway. Outside reads are refused after the
platform's own caller check, and search drops hidden hits.
A session transcript holds everything its turns saw, so the transcript tools
apply the same limit. admit() records the turn's channel and thread on the
Admission, with every id limiting its readers (its channel and a thread with
its own rule), and create_session stamps them on the session
(daimon_channel, daimon_thread, daimon_sealed=<ids>;
daimon.core.session_seal). The recorded ids only grow: a limited turn that
reuses a session adds its id (bind_session, which waits rather than run the
turn if Managed Agents refuses the update mid-turn), and a session that
replaces another -- by transcript, checkpoint, bundle, handoff or dead-session
recovery -- inherits its predecessor's ids, or is limited to its own thread
when the predecessor can't be read. Each read or follow-up requires the
calling turn's origin to be inside every recorded id, whatever the current
policy, and judges the channel and thread against the current policy as a
channel read would: the main MCP server's session tools take the calling
turn's origin_context_id, claimable only by a chat turn's own credential;
agent-chat keys run outside every channel, so they never list, read or
continue a limited conversation, and the hub does the same for members. A
workspace admin may list and read any limited conversation from the hub, and
a channel admin those of the channels they administer whose every recorded id
lies there too (the channel, a thread in it, or its channel:ts), but
continue none (see Trust model). Limiting a channel's readers
later covers its existing sessions, and opening it never releases a session
that ran limited -- only that thread, when the thread had its own rule. A
session from before the stamp that a thread ran on (thread_sessions) has no
known parent channel: while any readers are limited it is shown only to a turn
in that same thread. Nor can a limited turn open or drive another session to
carry its content out: agent chat's start_turn, ask and continue_turn
are off the surface a chat turn's token (chat_agent_id) sees, and refuse
that credential outright if they are ever reached with it
(_require_outside_chat_turn), so every session they create comes from a
headless caller outside every channel. The one exception is an agent key
minted with "Use from your coding tools" in a channel with limited readers, or
in a channel its agent's rule names (a thread counts as its parent; Teams
panels live in the 1:1 chat, so its dialog asks which channel): its
mcp_tokens row records that channel (coding_token_channel), and its calls
run as a turn there -- under the channel's rules, environment and budget, with
its sessions stamped to the channel (token_channel_id). authorize sees it
as the place of the key's turns (mcp_place) and as the read origin, nowhere
else, and re-decides both at the moment of action; the rule is read right
before a session is created, and a conversation opened before its channel's
readers were limited can be read but not continued. Keys minted anywhere else
are unchanged. A server admin mints anywhere; a channel admin of every channel
an agent's rule names mints for it only from inside one of those channels, and
that token is always bound there (authorize(MINT_CODING_TOKEN), through
authorize_coding_token).
Rules are set by server admins and operator tokens (channels:write), never
a channel admin: in chat with set_channel_rule and set_agent_rule, on the
channel's Permissions screen (from Who answers where in the setup panel;
Teams: the Channel settings dialog), or with the CLI. Each write is one transaction under the
policy lock (packages/core/daimon/core/channel_rules.py):
daimon channels rule set discord GUILD_ID CHANNEL_ID --readers own --writers own \
[--copy-from AGENT] # keep it to its own agents
daimon channels rule set discord GUILD_ID CHANNEL_ID --readers inside
daimon channels rule set discord GUILD_ID CHANNEL_ID --readers any [--release-agents]
daimon channels rule set discord GUILD_ID CATEGORY_ID --category --writers none
daimon agents rule set discord GUILD_ID AGENT --runs-in CHANNEL_ID [--runs-in ...]
daimon agents rule set discord GUILD_ID AGENT --anywhere | --nowhere
daimon tenants access-policy rules discord GUILD_ID [--json]
daimon tenants access-policy get discord GUILD_ID [--json]
daimon tenants access-policy set discord GUILD_ID --invoker USER_ID [--dm-memory-read-only]
daimon tenants access-policy set discord GUILD_ID --clear [--drop-agent-rules] # back to open
daimon tenants access-policy set teams ENTRA_TENANT_ID \
--add-member-guest OBJECT_ID [--remove-member-guest OBJECT_ID] # guests as members
An agent with a rule runs only in its listed channels and the threads under
them. The rule is keyed by agent name and checked against both the cascade's
name and the agent's own metadata name, so a thread handed to the agent by id
is covered; setting a rule replaces one on the agent's other names. A turn
anywhere else is refused with runs_elsewhere, a member's DM included.
Admins are exempt only where the reply reaches no one else -- their own DM
and hub turns -- and even there the agent's channel sends reach only its
channels (see Trust model). hand_off_task refuses to bring
it into another channel before anything is written. Its routine must post
straight into one of its channels (a channel destination, not a thread or
none), because the scheduler cannot resolve a Discord thread's parent at fire
time; it is refused at save and skipped at fire otherwise.
fork_agent is admin-only and refuses a source with a rule: a copy would be
the agent's prompt, skills and connectors under a name with no rule. A fork
also starts with no credentials (no GitHub access, repo binding or proof, and
no agent-wide MCP token), so copying an agent never hands out another
project's access; MCP servers that only work with a stored token are left off
the copy. The source's own uploaded skills are copied as new skills of the
fork's.
An MCP or hub turn (start_turn, ask, continue_turn, on a new session or
a resumed one) runs in no channel, so it is outside every agent rule, as a DM
is: the policy is read on every call, and an agent with a rule is refused
before any session is created or message sent. Only the operator's internal
tokens, which carry no platform user, bypass it.
Cross-agent separation is complete only for agents with a rule: an agent without one still answers wherever the cascade sends it, so give every client project agent a rule. Beyond the rule, whatever an agent can reach (its repo, keys, connectors and memory) is also guarded where a member could otherwise borrow it:
hand_off_taskand the Hand over button let a member hand a thread only to an agent of that channel: the one it answers with, one whose rule names it, or one of the channel's own agents. Any other destination needs a server admin, or a channel admin of the parent channel when the thread's readers aren't limited and they could make the agent its default: the same rule, which also refuses a move that would take it out of another channel admin's channels while it is theirs (below, and Same-thread handoff).create_routineandupdate_routinelet a member schedule only the agent they are talking to, or the agent the destination channel answers with. Routines are listed and read only by their creator and admins.fork_agentis admin-only and forks start credential-less (above).
A rule naming a channel kept to its own agents must name it alone, and makes
the agent one of them only if it could be the channel's own (custom, and
answering nowhere else); a channel's own agent keeps its rule until that
channel's readers change. An agent without a rule runs anywhere, so dropping
one is explicit (--anywhere, runs_in: null). access-policy set --clear
needs --drop-agent-rules while agent rules exist. Rule names must match an
agent of the tenant. Every supplied id is validated before writing: Discord
ids are 15–21 decimal digits; Slack user ids start with U or W, channel
ids with C or G, followed by uppercase letters or digits (a Slack thread
rule is keyed channel_id:thread_ts and only takes readers: inside). A
Teams rule names a whole 19:…@thread.tacv2 channel; a thread id names its
channel. Invalid input names the field and value and writes nothing.
A private DM conversation (dm: scope) is outside every agent rule wherever
it is checked: admission, hand_off_task, and continuations owed to a DM,
which are admitted as DM turns. Adding a key, connector token, skill-repo
token or repo binding to an agent with a rule (request_agent_key,
request_mcp_token, request_mcp_oauth, request_skill_repo_token,
request_repo_binding) or pointing it at a public repo (bind_public_repo)
needs an admin, a channel admin of every channel its rule names, or a request
made inside one of those channels. The form's submit (Discord, Slack and
Teams) re-checks the rule against the agent as it is now, resolved by its
stable id and checked by every name a rule can be keyed by
(core/agent_pins.py), so a rule added later or a rename still holds; a
target that can't be resolved while agent rules exist is refused. The direct
configuration tools (update_agent, attach_mcp_server, detach_mcp_server,
remove_agent_key, remove_skill) check it with no turn origin (an
origin_context_id only finds the agent), so on an agent with a rule they are
an admin's or a channel admin's of every channel it names; members inside its
channels use the request tools. An agent key's self-edit tools
(set_repo_binding/clear_repo_binding/self_write_file/self_delete_file)
are refused on an agent with a rule: an agent key's stored roles are never
trusted, so neither exemption applies to it. One guard
(tools/_pin_guard.py) serves all of them. A sign-in (request_mcp_oauth)
is re-checked when its callback arrives, before any grant or attach. Routines
are checked against every name of the agent they run (at save and at every
fire, after the scheduler self-heals to a replacement agent), and so is
hand_off_task's destination.
Channel admins. A tenant can name, per channel, groups and members who run
that channel on top of the server admins (channel_admins,
packages/core/daimon/core/channel_admins.py). A group is a Discord role, a
Slack user group, or a Teams team, whose owners it admits; a Teams member is
named by Entra object id. Discord sends the member's roles with each event.
Slack and Teams look up only the groups some grant names
(usergroups.users.list; Graph's team owner list under the
TeamMember.Read.Group consent), each cached for a minute, and a failed lookup
grants nothing. admit() stores the member's matched group ids on the account
(accounts.platform_role_ids) beside the role, so MCP tools test a grant
without asking the platform for Discord roles. After admission, Discord and
Slack turn controls also name a channel admin grant for the current parent
channel; the model can then call setup tools, which independently check the
target agent's reach. This does not change the account's server-admin role.
Every Slack member can edit
user groups by default, so a group grant admits whoever can join it and the
workspace should limit group management to admins; outside a turn (the MCP
verifier, hub reads, an OAuth callback, channel admin DMs) a stored Slack
group or Teams team counts only while a live lookup still admits the person
(confirm_stored_group_ids), and a private form's submit, which runs under
the policy lock, ignores them. A channel admin may do what a server admin may for
an agent of theirs that is local to their channels -- not the tenant default or anyone's
personal default, answering or running somewhere and only in channels they
run (channel-scope rows, thread bindings, and other people's live sessions
and routines, each by its channel), and no unattended run of it owed to a
server admin or another channel's admin
(packages/core/daimon/core/agent_reach.py) -- and may set or clear those
channels' default agent (never a budget or the tenant balance, which stay
with server admins: SET_CHANNEL_BUDGET). An agent is theirs when a channel
admin made it from one of their channels (agent_creation_channels, written by
create_agent from a verified turn origin and by each setup panel's New agent
form), when a server admin's rule runs it inside their channels only, or when a
server admin set it as one of their channels' default
(channel_config.agent_name_set_by_admin; migration 0044 backfilled it from
each setter's role at upgrade time, not when they set it); any other agent needs a server
admin on every surface (channel_admin_holds). Handing a thread to an agent makes it answer
there as a default would, so binding and handoff are one rule
(authorize(BIND_CHANNEL_DEFAULT) and authorize(HAND_OFF) with load_binding_reach):
neither may take an agent out of another channel admin's channels while it is
theirs, read over all their user and group grants together. It guards these two
moves only; a member's routine, for one, still adds a channel to an agent's reach. A /dm conversation counts as the channel it was
started from. A session counts in the channel recorded when it was created
(thread_sessions.channel_id) and in any its spend was attributed to, and a
routine in the one its spend counts against; one with none recorded could run
anywhere, and the refusal says so. An agent answering nowhere is local to
nobody, so locality only narrows what key and MCP server replacements and
removals and skill repo connects count as shared, never past it. In daimon.core.authz a channel admin
is Subject.administered_channel_ids, filled from the stored grants and never
is_admin: configuring an agent with a rule from anywhere is theirs once
they administer every channel each of its rules names (a rule naming no
channel stays with server admins), and so is minting it a coding-tools token bound to one of
those channels (never an unbound one). A channel admin binds only a shared agent
(managed or tenant-wide), or one a channel admin made from one of their
channels or a server admin's rule runs inside them; a server admin's default does
not make an agent theirs to move, and another channel's own agent never is. No chat tool, panel or CLI
write (daimon config set, daimon config propagate) binds an agent with a
rule as the default of a channel outside it, for server admins too
(authorize(BIND_CHANNEL_DEFAULT)). Managed agents
and the tenant default stay with server
admins, and a tenant with no grant behaves as before. Stored role ids refresh on
the member's next chat turn; until then MCP calls, a coding-tools token
included, keep the old grant. Unattended runs are routines and queued wakes
(timers, handoffs, applied private input); each fires with its requester's
rights, so a channel admin's edits reach them as they reach anyone chatting
with the agent. A member's run carries that member's own read visibility, as
their chat does, and a server admin who chats with an agent a channel admin
edited runs its instructions with their own rights, as with any agent someone
else wrote. The check reads requesters' rights at edit time, as stored at
their last chat turn: a requester promoted later runs earlier edits with the
new rights. Server admins edit grants
with the *_channel_admins MCP tools, from Who answers where in the setup
panel, or with the CLI (discord, slack or teams; a Slack or Teams thread
id names its channel):
daimon channels admins get discord GUILD_ID [CHANNEL_ID] [--json]
daimon channels admins set discord GUILD_ID CHANNEL_ID --role ROLE_ID --user USER_ID
daimon channels admins clear discord GUILD_ID CHANNEL_ID
Channels kept to their own agents — packages/core/daimon/core/channel_rules.py.
A channel C with readers: own keeps its own agents to itself: those whose
agent rule names C alone (agent_permissions(...).home, by every name the
agent carries). set_channel_rule does it in one write under the policy lock:
it sets C's rule and gives C's default agent a rule naming C. The default must
not be built in, have a rule naming elsewhere or answer anywhere else
(agent_reach, checked at write time only); otherwise the call is refused
with the reason, unless asked for a copy. Then copy_from, or whoever answers
in C, is copied by agent_fork.copy_agent (authorize(FORK): an admin's
call, never an agent with a rule; no credentials, no agent-scoped skills,
which the reply names) under a name from the channel, made C's default and
given its rule; a copy the locked re-check refuses is archived. Moving readers
off own keeps the agents' rules unless release_agents drops them, and warns
that they keep what they remembered in C.
Enforcement is authorize()'s: it fills AgentRef.permissions and
Place.permissions from the policy (permissions), so every
check and re-check is fresh. In C only C's own agents run, post, read, get
routines or become the default (own_agents_only on RUN_AGENT, POST,
READ_CHANNEL, SAVE_ROUTINE and BIND_CHANNEL_DEFAULT); a setup thread
under C (Place.setup_thread) still answers as the built-in agent. C's own
agents post nowhere outside C, not even the requester's DM, and send no direct
messages (DIRECT_MESSAGE) or create agents (CREATE_AGENT, also refused for
any call whose verified turn origin is in C, such as its setup thread), whose
prompts would answer outside C. Publishing (PUBLISH: publish_report, the
notebook and attachment upload URLs and set_display_identity, each seen
outside C) from C, or by any agent with a rule, waits for the requester's
Approve (publishing); a chat turn naming no
verified origin while some readers are own is refused, as for
CREATE_AGENT. Admission, reauthorize and the scheduler's fire check (the
resolved agent, by every name, at the routine's destination) decide through
RUN_AGENT; thread participation skips a refused turn before its classifier
runs. Memory stays writable for C's own agents in C and is read-only for any
other agent there, as with readers: inside; a session opened with a
coding-tool token bound to C follows the same rule. A session whose recorded
ids lie in C is read and continued only by C's own agents (READ_SESSION,
CONTINUE_SESSION). A verified turn origin in C holds the call to C whatever
agent runs it (origin): its posts, cards and routines stay in C and it sends
no direct messages; only its setup thread may still configure C's own agent.
Held to C, by its own agent or such an origin, a call also reads only C and
the sessions that ran there (own_agents_only on READ_CHANNEL,
READ_SESSION, CONTINUE_SESSION; home_hold); a session with no channel
stamp counts as outside C. A chat token names no turn, so while one of its
turns runs in C a call is held there whatever origin it names (_held_origin;
turns running in two such channels refuse it). Held, list_channels and the
search tools name nothing else, so nothing read elsewhere, a prompt planted in
C included, is reposted into C. A thread routine saved without its parent
channel is treated as inside any such channel until delivery places it
(Place.parent_unresolved). The rule constrains agents: a caller with no
executing agent (an operator token, the CLI) may still post into C, which is
input, not a leak; the readers rule keeps reads inside.
What callers see follows from where they stand. An MCP call is inside C when
its verified turn origin is in C, when it carries a channel-bound coding-tool
token for C, or when its chat turn's agent is one of C's; an agent key is
never inside by its agent alone. The roster, agent and key tools take the
turn's origin_context_id for this. A chat turn whose agent can't be found
is refused while some readers are own, since it may be one of C's. From
outside, C's agents are missing from list_agents and every by-name lookup,
from handoff destinations, explain_agent_resolution and the hub, and so are
their agent-scoped skills, their routines, routines posting into C and timers
set in C; inside C only C's agents show. For members the setup panel's
roster, details and Who answers where are filtered the same way at the
panel's location, and /memory hides an agent wherever it may not run;
server admins see everything. Server admins are exempt in their own DM and
hub, and a channel admin of C there too, but C's agents still never post
outside C. get_tenant_summary lists each channel with own_agents_only.
Limits: tools on other MCP servers don't see the policy; an agent created
inside C isn't C's own until its rule names C; /dm from C is refused; a call
is held to C only where its tool takes a verified origin (not the send, DM,
self-edit or routine edit tools), and a call that names none is judged from
outside.
When C closes, archive_channel_copy (server admins, or the agents:archive
operator scope; core/channel_copies.py) archives the copy set_channel_rule
made for it, stamped daimon_isolation_copy, with its rule and default in C.
It archives no other agent and no default, and refuses a copy whose rule or a
default names anywhere but the channel named as closing. C keeps its rule, so
nothing answers there after.
Channel environments. The environment a turn runs in resolves over the
same tiers as the agent but on its own (_pick_environment in
packages/core/daimon/core/scope.py), so a channel can keep its agent and run
it with the packages one team needs; routines follow the channel they post to.
Server admins set any channel's environment, or the tenant default by omitting
the channel, with set_channel_environment and clear_channel_environment; a
channel admin sets the channels they run, and a thread id resolves to its
parent. Who answers where in the setup panels lists each channel's
environment and gives server admins and this channel's admins a select for it
(on Teams, in the Channel settings dialog, for a channel picked there)
(packages/core/daimon/core/channel_environments.py); rows on the other
side of a channel kept to its own agents are dropped, and inside one the
workspace and deployment environments too. The name must match an existing
environment in the tenant, looked up once so the network rule and the write
judge the same one; conversations pick it up from their next message, keeping
their files and their recorded readers, and explain_agent_resolution reports each tier's
environment. authorize(SET_CHANNEL_ENVIRONMENT) decides every pick: in a
channel with limited readers, or one holding such a thread (a Slack
channel:ts, or a Discord thread the pick names), an environment with unrestricted
networking (any network beyond package managers and MCP servers: anything but
a cloud environment on limited networking with no allowed hosts) needs a
server admin, and so does clearing a pick onto a default that has one. Even a
server admin's such pick waits for a confirmation (EnvironmentPick.needs_confirm):
set_channel_environment and clear_channel_environment take
confirm_open_network, which the model passes only once the caller confirms,
and the panels write nothing and point to chat. A pick
made before the rule never met it, so limiting a channel's readers when its
own pick is open warns that a server admin should confirm it; who made
a pick isn't recorded. An operator token's
channels:write covers a channel's environment, never the tenant default. An
environment name only channels kept to their own agents across the reader's
line pick could name a client, so list_environments, get_environment, the
environment-changing tools and the panel pickers treat it as missing, and
get_tenant_summary blanks it, with other such channels' agent names
(hidden_environment_names); operator tokens, and server admins on the
panels, see every name. A
channel with no environment of its own falls through, so nothing changes until
one is set. Chat over MCP has no channel, so it uses the tenant or deployment
default. The channel tools read a Slack or Teams thread id as its channel and
a Discord thread through a lookup (tools/_channel_target.py); budgets and
environments also check the caller can see the channel.
Channel skills — packages/core/daimon/core/channel_skills.py. A channel
can add skills to whatever agent answers there, for its turns only, so a
shared agent carries one team's skill without every channel getting it.
Server admins and operator tokens (channels:write) add and remove them,
never a channel's own admins (authorize(SET_CHANNEL_SKILLS)), with the
*_channel_skill(s) MCP tools, Who answers where in the Discord and Slack
setup panels, or the CLI:
daimon channels skills list slack TEAM_ID [CHANNEL_ID]
daimon channels skills add slack TEAM_ID CHANNEL_ID SKILL
daimon channels skills remove slack TEAM_ID CHANNEL_ID SKILL
A channel may add a library skill of its tenant, or one uploaded to the agent
answering there now, unless that agent is another channel's own.
The row (channel_skills) pins the latest version at add time; adding it
again picks up a newer one. Admission reads the rows once per turn
(turn_channel_skills) and drops another agent's upload, a skill the agent
holds, a clashing mount name and anything past the session cap; the session
is created with the agent's skills plus these, and the drift check hashes the
same list (session_snapshot.session_skills), so adding or removing one
replaces the conversation's session on its next message. Routines, headless
runs and MCP chat don't use them.
Skill uploads. One skill can be added to one agent by hand
(packages/core/daimon/core/skills/ingest.py checks it, skills/add.py adds
it): a pasted SKILL.md, a .md or .zip attached on the caller's own platform,
or a GitHub folder read through the skill-repo fetch. daimon skills add --agent
NAME PATH|URL adds a local folder, SKILL.md or .zip, or a public GitHub folder,
from the CLI: it previews, asks unless --yes, and decides as a server admin
(never a built-in agent, the agent rule asked again just before the upload and
the attach). Archives refuse links,
absolute or .. paths, encryption and the repo sync's size caps; the
frontmatter needs a lowercase name and a bounded description. The skill is
uploaded under the agent-scoped title, never the shared library. A name a
shared or built-in skill already holds, or one that would load under the same
folder as an attached skill, is refused. A fork (copy_agent, also behind
set_channel_rule's copy) uploads the source's own skills again under the fork's
title and upload row, so the two never share a skill id; another agent's
skill, or one that fails to copy, is left off and named, and the fork still
succeeds. One attached by id or kept by an older fork may still be
shared, so a skill another agent also has attached is never versioned; the
upload must take a new name. Its user_skills row has source = "upload", an origin (a GitHub
origin is owner/repo/path@branch, never the URL as typed) and the adding
account, so no repo sync replaces, deletes or re-attaches it, and removing the
skill forgets it. add_skill previews first and adds only when called again
with the preview's hash, which is bound to the target agent, and only after the
person presses Approve on the confirmation card (below). The server checks the
card can exist: the confirm needs a verified origin whose live session runs the
origin's responder with add_skill on always_ask (has_confirmation_gate),
as MA reports the session or, only when it reports the agent's own tools
(leaving the per-session overrides out), as the bind recorded sending them. Without that, as with tool safety off, an
agent_chat session or one whose tools have not caught up yet, a chat
confirm adds nothing; the preview and the refusal say which case it was and
point to Add skill in
the setup panels' Details (Discord takes a paste or a file, Slack a paste),
where the person's own submit is the approval. The skill_add and
skill_remove operations follow the shared-agent rule
(authorize(CHANGE_SHARED_AGENT), spec family): built-in agents never, server
admins on any other, channel admins on agents of theirs local to their channels, anyone
on agents nobody else uses. Sharing is read as widely as a key change
(WIDE_SHARING_OPERATIONS): a default, a bound thread, someone's personal
default, or another member's routine or live session. Prompt and setup edits
(agent_spec_edit) and repo binds read sharing the same way, so an agent that
only an admin's routine or a bound thread runs is not a member's to change. An
agent with a rule takes a
chat add only from a verified origin in its channels (require_pin_write_access
with the card's origin), and a panel add only from its channels' panels
(pin_refusal with the panel's channel and thread) at the button, the submit
and the Add; remove_skill, like the other direct configuration tools, passes
none. Rules and sharing are checked again on the fresh agent just before the
upload and the attach, and refusals never name another agent. The target resolves from the turn's channel, so a
setup thread under a channel kept to its own agents reaches only them, and
nothing outside reaches them. Their uploads are hidden outside it like its other agent-scoped
skills, keyed by the upload row's agent.
Stage two, bind_session() — packages/core/daimon/core/turn/prepare.py.
Finds the live thread_sessions row for this thread or creates a fresh MA
session, assembles every create_session argument (credential env mount, MCP
vault, repo resource, memory store), writes the mapping row, and binds the
usage recorder. It returns a frozen PreparedTurn whose recorder field is
underscore-prefixed: adapters never construct billing wiring, and the only way
to reach the recorder is to hand the PreparedTurn back to stage three.
Whether an existing session may be reused, refreshed in place, or must be
replaced is decided in packages/core/daimon/core/session_preparation.py
against the fingerprints stored on the mapping row; the outcome rides back on
PreparedTurn.continuity so the adapter can say what happened.
Stage three, run_prepared_turn() —
packages/core/daimon/core/turn/run.py. Calls the driver, and on a
dead-session 404 recovers exactly once: mark the mapping dead, create a
replacement, read the dead session's event log back into the reseeded message,
rebind the recorder, re-run. A second dead signature is returned as-is.
Both stages two and three are wrapped by one shared per-turn ceiling from
packages/core/daimon/core/turn/ceiling.py — TURN_CEILING_S, 45 minutes. It
is a backstop against an MA session that never leaves running, not a latency
target; legitimate turns that fit a model or build a notebook run for many
minutes. admit() sits deliberately outside it.
The driver¶
packages/core/daimon/core/turn/driver.py opens the SSE stream, posts the
user message, and runs a consume loop and a render loop concurrently until the
session goes idle or errors. Adapters plug in through the TurnLifecycle
protocol in packages/core/daimon/core/turn/lifecycle.py, which documents a
per-hook cost contract:
on_render is the answer-text delivery path and may talk to the network,
because it runs on its own task and cannot stall the pump; on_sse_event is
awaited inline in the consume loop and must stay a cheap local tap.
After a tool-using Discord or Slack turn, the adapter starts a detached,
per-MA-session-chained sweep of downloadable session files through
daimon.core.output_delivery. It posts each file into the conversation thread
before deleting its MA listing entry. Failed posts stay listed for a later
sweep. Discord uses the guild's upload limit, skips oversize files with an
in-thread notice, and checks the channel's writers before posting.
Reconnection is two loops for two failure modes. The outer loop handles
eventless cycles — the server closes cleanly roughly every ten minutes by
design — and asks MA whether the session is still running before reconnecting,
so silence can never be mistaken for a truncated success. The inner
AsyncRetrying block is a bounded two-attempt budget for a genuinely dropped
connection. The outer loop has no attempt cap; the per-turn ceiling is its
only backstop.
Every call must declare a billing posture, from
packages/core/daimon/core/turn/posture.py: Billed meters each
span.model_request_end event through the bound recorder, BillingExempt
meters nothing and logs why.
It also declares a tool-confirmation posture: what the driver does when MA
pauses the session on a requires_action idle. RequireApproval ends the
turn, AutoApprove allows every blocked call, and PolicyApproval asks a
decider per call and sends each answer on the same stream. The decider comes
from packages/core/daimon/core/turn/approvals.py, over the pure read/write
model in packages/core/daimon/core/tool_safety.py. With
DAIMON_TOOL_SAFETY__ENABLED on, create_session sends every attached
third-party toolset as always_ask (a per-session override, so it holds
however the agent was written); reads then run, a write in a routine is
refused unless the operator allowed it there, and a write in chat waits for
the requester to press Approve on a confirmation card. The card is a platform
hook: run_prepared_turn(confirm_write=...) takes a ConfirmationHook
(packages/core/daimon/core/confirmation.py), Discord, Slack and Teams each draw the
shared card from packages/core/daimon/core/posted_controls/confirmation.py,
and an adapter that passes no hook gets no_confirmation_surface, which
refuses the write. Plugins can build their own ConfirmationPrompt and call
the same hook. Daimon's own daimon-mcp tools are not gated here (they keep
their operation_policy checks), except add_skill: its confirming call, the
one naming a preview's content_hash, is sent always_ask, so it waits on
the same card in chat and is always refused in a routine, whatever
unattended_writes allows. Each bind compares a reused session's tools with
the gated ones a new session would get and updates them in place, so a
session started before tool safety was turned on is gated from its next turn,
and a tools change never writes the agent's own always_allow back. The
exemption holds only for the deployment's verified
endpoint: with the policy on, create_session re-points a daimon-mcp entry
naming any other URL at DAIMON_MCP__PUBLIC_URL, and without a public URL the
reserved name is gated like any other server. A pending card is owned by the
turn: stopping the turn, a replayed pause, or the turn ceiling cancels the
decision, refuses the call and retires the card, so an Approve that arrives
after Stop never runs anything. That cleanup is best effort within a few
seconds per step (CLEANUP_BUDGET_S): a chat platform or MA that stops
answering cannot hold a turn past its ceiling or a Stop.
How a turn ended¶
packages/core/daimon/core/turn/termination.py defines TerminationReason,
one closed enum for every way a turn can end: it completed, the user stopped
it, the stream or MA failed in one of several named ways, the ceiling fired, or
admission or binding refused it before a driver ran. Each driver finalizer, and
both ceiling handlers, set TurnState.termination before the terminal hook
fires, so a lifecycle and the caller's RunOutcome.termination always agree.
Refusals raise before any state exists; termination_reason(err) maps the
exception the adapter caught to its member, and never raises: anything it does
not recognise is unknown. Each AdmissionDenied reason with a member of its
own (balance, cap, channel budget, writers none, agent rule, own agents only) maps to it, and
denial_termination_reason gives the same member to a gate that decides with
authorize instead of raising; the rest are admission_denied. Two members have no exception behind them and are
set outside the mapper: admission_concurrency_shed by callers when
should_admit_turn refuses, and recovery_failed by run_prepared_turn on
the terminal hook when replacing a lost session raises (the exception it
re-raises maps to unknown). A session MA reports terminated without any terminal
event for this turn is session_terminated, never completed.
TurnError.kind is unchanged, and
every TurnKind value is also a TerminationReason value with the same
string.
packages/core/daimon/core/turn/notices.py turns a reason into a
TerminationNotice: a short headline, the cause, the tool work still running
and how much had finished, what survived, the next step, and a request id. The
copy lives in core; Discord and Slack draw it in on_terminal_failure as the
body of the red card, with the headline as the footer reason, and log the
request id with the underlying error so it is the handle for the detail;
Teams draws it as plain text on the ❌ card. No
lifecycle hook carries it -- the reason rides on the state every lifecycle
already receives -- so the CLI, headless routines and any new adapter keep
their existing failure path, and TerminationNotice.plain_text() is the
fallback wording for a surface without markup.
Anthropic's monthly spend-cap response stops SDK retries at the HTTP transport.
If Anthropic reports that cap or a user-set spend limit, Discord, Slack and Teams
show a model usage limit notice and log anthropic.spend_limit_reached with
the tenant and limit type.
Outside text is data¶
Anything daimon quotes into a turn from someone other than the person asking
goes through one envelope, packages/core/daimon/core/untrusted.py: an
element marked trust="untrusted", opened by a fixed line saying the content
is data, not instructions, with every value escaped so the content cannot
close the element early. The Discord, Slack and Teams context builders wrap
replayed thread history, deltas and channel backfill in it; fetch_youtube_transcript
returns its transcript in it; the quoted transcript on a workspace
replacement (render_previous_session) uses it too. The channel read and
search tools return JSON rows, so their results carry the same marker as
trust and trust_note fields instead. The paragraph in the agent guidance
block (packages/core/daimon/core/agent_guidance.py) tells every agent what
the marker means. Only the <user_query> is the request.
Third-party MCP tool results travel from Managed Agents straight to the model without passing through daimon, so they carry no marker; the guidance paragraph covers them by name ("whatever a tool returns").
Trust model¶
Permissions has the rules as one model; this section has who is exempt and why.
Admins are trusted; rules protect members and channels. An agent rule and a channel's readers rule exist to keep one client's context away from other people -- members of other channels, and anyone reading where an agent posts -- not to restrict a workspace admin. So an admin is exempt only where the output reaches no one but them:
| Surface | Members | Admins |
|---|---|---|
| Channel, thread, handoff, routine that posts to a channel | rules apply | rules apply |
DM (admit(is_dm=True), Teams personal chats included) |
agent with a rule refused | agent rule exempt |
Hub ask / start_turn / continue_turn |
agent with a rule refused | agent rule exempt |
Hub list_my_sessions / get_session / list_events on a conversation with limited readers |
refused | allowed, anyone's; a channel admin's in the channels they administer |
Hub continue_turn / ask(handle) on such a channel conversation |
refused | refused: continue it in its channel |
| Credential and configuration tools on an agent with a rule | from inside its channels only | allowed (a chat turn's admin, or channel admin of every channel it names) |
fork_agent of an agent with a rule |
refused | refused |
| "Use from your coding tools" | refused | allowed (a channel admin of every channel its rule names: bound to one of them) |
| Agent chat and any agent-scoped key or bearer token with no platform user | rules apply | rules apply |
| An agent key minted in a channel with limited readers, or one its agent's rule names | runs inside that channel only | runs inside that channel only |
A channel's own agent, or any agent from a turn inside a readers: own channel |
runs, posts, reads and lists only in its channel; sends no DMs; publishes once the requester approves | the own agent also runs in their own DM and hub (so does a channel admin of that channel), but still posts and reads channels nowhere outside it; the hub's conversation reads above still apply |
Channel and agent rules, the invoker allowlist and fork are decided by one
pure function, daimon.core.authz.authorize (who is acting, what they want to
do, where the result lands, which agent, which channel); the turn pipeline,
the MCP gates, the channel tools, the routine and handoff tools and the fork
paths gather their facts and ask it, including the hub's admin and channel
admin read of a limited conversation and the refusal to continue one. The
scheduler's writers and invoker checks and the shared-agent replace/remove
table are still decided where they are.
Decided again at the moment of action. Admission is a decision about a turn that has not run yet, so it is asked again, on the policy as it is then, where it matters:
bind_sessionand the dead-session recovery calldaimon.core.turn.admission.reauthorizebefore a session is found, reused, replaced or created. A rule or invoker change since admission refuses the turn there; a readers limit added since joins the turn's recorded ids, so the session is stamped with it and memory mounts read-only.- Every channel send, card and direct message reads the policy when it is made, as do the routine fire, the handoff and the form submit checks.
- The MCP OAuth callback asks the agent rule's write check again after the code exchange and before the grant is written to the vault.
- A published report's reader variant carries its source agent's names
(
daimon_reader_source), so a rule on the source holds for the reader, and publishing the reader of an agent with a rule needs an admin or anorigin_context_idfrom inside its channels. A channel's own agent's reader is never published (require_reader_source_publishable), admins included. - An agent-scoped key is never exempt as an admin inside
authorize, whoever minted it, and never holds its minter's channel admin grants (build_subject), so a channel-bound key reaches no channel but its own.
A demoted admin keeps their stored role until their next platform turn refreshes it; that is accepted.
On every turn, wherever an agent with a rule runs (including an exempt admin turn and a member's turn inside its channel), its sends (messages, replies, threads and posts, files and cards on Discord, Slack and Teams) reach only: its rule's channels and threads under them; the requester's own 1:1 DM with daimon (a Slack IM whose user is the requester, or a Teams personal chat the requester is in); and direct messages to the requester. Its context never lands in another channel or another person's DM. A channel's own agent is stricter: it posts only into its channel and the threads under it, never the requester's DM, and sends no direct messages.
readers: own constrains agents, not callers. Inside such a channel C only
C's own agents run, post and read; a caller with no executing agent, such as
an operator token or the CLI, may still post into C. That is input, not a
leak: the readers rule keeps C's messages readable only from inside it. A
session that ran in a DM (a Slack IM, a Teams personal chat, or a /dm
conversation) is private: admins never read it from the hub.
A channel admin has a server admin's rights limited to the channels they
administer, so only those rows' exemptions apply to them, and only there:
configuring agents with rules, coding-tool tokens, hub reads of their
channels' limited conversations, and readers: own, where a channel admin of
C is exempt in their own DM and hub for C's agents only. That grant is the
person's, never an agent key's. Other hub turns, DMs and forks treat them as
members.
Continuing a limited channel conversation from the hub stays refused for admins because a follow-up would join the channel's own conversation, which the channel goes on reusing. Branching a private copy for the admin is a possible follow-up.
Who counts as an admin:
- In admission (DMs), the live role the adapter passes for this turn.
- For credential and configuration tools, a chat turn's credential, whose
is_adminreads the account's stored role -- recorded from the platform by that turn's admission, and as current as the person's last platform turn when the same vault token is reused by their hub or routine sessions. The operator's own internal token is trusted too; an agent-scoped key never. - In the hub, the account's stored role, and for a channel admin the stored grants matched against the role ids of their last turn. The hub has no live platform role, so a demotion or promotion takes effect on the person's next Discord, Slack or Teams turn in that workspace, which records the platform's current role.
An operator token (below) is an admin only while its account's stored role is, checked on every request. Like the hub, it sees a demotion only once the person's next platform turn records it, so revoke the token to stop it at once. It always carries a platform user, so it is billed and rule-checked like that admin's own chat turn, never trusted as the operator.
Agent-scoped keys, chat-turn credentials in the hub and tokens with no platform user are never admins on these surfaces, whatever role their account holds or who minted them. An agent rule binds a bearer with no platform user too: such callers skip billing, not admission.
The agent rule decisions (turn admission, MCP and hub turns, routine save and
fire, handoff, configuration writes and form submits), sends and direct
messages, channel and session reads, fork, channel admins' configuration
rights and channel default binds are decided by authorize; each caller keeps
only its own I/O and refusal copy. The live writers and invoker checks in the
scheduler and routine delivery and the OAuth no-request rule still use the
same permissions predicates directly.
Tenancy and isolation¶
One Discord guild, Slack workspace or Teams (Entra) organisation is one tenant. The tenant UUID is
derived, not allocated: derive_tenant_uuid(platform, workspace_id) in
packages/core/daimon/core/ma_identity.py is a UUID5 under a frozen
namespace, so the same workspace maps to the same tenant across database
resets and processes.
Isolation is enforced in two places at once.
In Postgres. Tenant-scoped tables carry tenant_id with a cascading FK to
tenants.id, and stores take tenant_id as a parameter rather than reading
it from anywhere ambient. Erasure is not left to cascades:
packages/core/daimon/core/purge.py deletes every row referencing a principal
in FK-safe order in one transaction, and
packages/core/daimon/core/privacy.py is its read-only mirror for the preview
panel. A schema-reflecting drift-guard test fails when a new person-scoped
table joins one path and not the other.
In Managed Agents. One deployment runs on one Anthropic key, so tenant
separation inside the MA workspace is carried by metadata stamps defined in
packages/core/daimon/core/defaults/metadata.py — daimon_tenant,
daimon_account, daimon_name, daimon_managed, daimon_spec_hash. The
resolver checks the daimon_tenant stamp on every cached-id retrieve, so a
stale id belonging to another tenant is rejected rather than used. Credentials
never enter a prompt: they are mounted into the session as an encrypted env
file (packages/core/daimon/core/credential_env.py) or brokered per call
(packages/core/daimon/core/broker/), and the MCP server the sandbox calls
back into authenticates a JWT whose agent_id claim is the derived agent
UUID from packages/core/daimon/core/ma_identity.py.
Because that env file is sourced in the sandbox, credential_env.py
serializes every value so bash cannot expand or execute it, and
packages/core/daimon/core/env_file.py decides which key names may be
stored. Names that control an interpreter, archiver, loader, locale, package
manager, git, an HTTP client or a CA bundle, or that redirect an SDK's own
endpoint (TAR_OPTIONS, BASH_ENV, LD_PRELOAD, GIT_SSH_COMMAND,
*_BASE_URL, …) are hard-denied for everyone (the four git commit-identity
names GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME and
GIT_COMMITTER_EMAIL excepted) and dropped from the mount even
if stored earlier; a non-admin member may additionally add only a secret
name (ending in _KEY, _KEY_ID, _TOKEN, _SECRET, _PASSWORD,
_PASSPHRASE or _PAT — never an identity, region or *_URL/*_HOST
name, which only an admin may add). This keeps one tenant member from handing another client's
agent code execution or a redirected connector through a key value or name.
Sessions and Managed Agents¶
A turn does not create a session per message. thread_sessions maps
(tenant, platform, thread) to one MA session id, and its status column
carries the lifecycle: live is the caller's current session, dead is one MA
no longer has, superseded points at the successor that carries its work
through replaced_by_id, and retired is an explicit fresh start. That
lineage is why a thread can survive a session replacement with its context
intact.
What MA holds is the agent, the environment, the skills and the session
transcript. What Postgres holds is metadata: identity, the mapping above, the
config cascade, credentials and billing. An MA session freezes its agent spec
at creation time, so the agent read at admission is not necessarily what
executes — the mapping row stores a SessionSnapshot of the configuration the
session is actually running (packages/core/daimon/core/session_snapshot.py),
and that snapshot, not the current spec, is what later turns compare and bill
against.
Same-thread handoff¶
A thread can move to another agent without a new thread. Two triggers:
- Ask the agent in the thread ("hand this to research-bot"). It calls
hand_off_taskwith the destination's id. This works on Discord, Slack and Teams. - When a channel's agent changes, a thread whose session belongs to the old agent can't run a turn, and its next mention gets a notice instead. On Discord and Slack the notice has a Hand over button that moves the thread to the channel's agent for whoever clicks it. Teams has no button; the notice there says to start a new conversation.
Asking the agent is the default because it is how every other thread change works (keys, repo, fresh start). The button exists because no agent can answer in that stuck thread, so there is nobody to ask.
Both go through daimon.core.thread_handoff.hand_over_thread, which decides
authorize(HAND_OFF) and writes the thread binding in one transaction. It
takes the tenant policy lock (lock_access_policy), then the requester's
account row FOR KEY SHARE, then the binding row FOR UPDATE, and reads the
policy after the first lock. A policy edit committed before the switch refuses
it; an edit that arrives later waits for the commit. The module docstring
gives the order against every other lock holder. A Hand over click by an admin,
or one authorize refuses, records a panel:handoff audit row, as
hand_off_task's refusals are audited.
The switch writes only the binding. On the caller's next message the bind
replaces the old session with one for the new agent: the old session's
transcript and files are carried across by the workspace transfer, the
successor is stamped with the old session's recorded ids, memory is read-only
when the thread's readers are limited (or a DM with dm_memory_read_only), and
the old row is
marked superseded. The work is carried only when the new agent could read the
old session itself from this thread (authorize(READ_SESSION)); otherwise the
new session starts with nothing. A target its rules refuse here is refused by
admission before any of this runs.
Entry points that are not a chat message¶
-
/herein Discord and Slack reads access policy and credential names, then renders one ephemeral card throughdaimon.core.here_card.where_am_iin the MCP adapter returns that same card and structured facts when the agent is asked about its identity, reach or credentials. The model does not compose the card. A single line identifies the answering agent and routing tier;/agent-setupandexplain_agent_resolutionprovide routing detail. The card shows the channel's stored reader/writer rule, a stricter thread or category rule, and the resulting rule here. It shows the responding agent's visibleruns_inchannels and home, who may answer, whether that agent can read here, whether reads stay inside and memory is writable, and whether publishing needs the requester's approval. These answers usedaimon.core.permissionsandauthorize. Its channel-turn view uses member visibility even for an admin, because the model's answer may be posted to the channel. Slack slash commands have no thread identifier, so Slack/herereports channel routing and says when thread routing could not be checked. Discord read and scoped search tools explain when the bot lacks View Channel or Read Message History permission, including on an otherwise empty read. -
Scheduled routines go through
packages/core/daimon/core/headless_runner.py, which creates a session with the samecreate_sessionthe chat path uses and delegates the drain to the same driver under the same ceiling — but it calls neitheradmit()norbind_session(). The scheduler runs the balance, cap and channel budget gates itself before each fire. A routine with a destination is told where its result goes; if the agent does not post there, the row's outbox goespendingand the chat adapter for the tenant's platform posts the result tail through its delivery poller (daimon.core.routine_delivery), after the access policy's writers and invoker checks. See routines.md. - MCP agent-chat tools, in
packages/adapters/mcp/daimon/adapters/mcp/tools/agent_chat.py, let a caller drive a session directly. They do not use the chokepoint either; they re-run the same balance and cap gates through_admitinpackages/adapters/mcp/daimon/adapters/mcp/tools/_ctx.pyand create sessions viadaimon.core.sessions.create_session. A run of a channel's own agent, a hub run by an admin or that channel's admin included, is gated by and charged to that channel's budget. The billed media tool runs the same gates, then the budget of the channel named by the calling turn'sorigin_context_id, and charges its spend to that channel. daimon run, inpackages/adapters/cli/daimon/adapters/cli/run/command.py, is a single-turn subprocess entry point that callsrun_turndirectly withBillingExempt.- Wakes run a turn in an existing thread later, with nobody mentioning the
bot: a handoff's first turn for the new agent, work unblocked by a private
form, a one-shot timer (
daimon.core.continuity.timers, thecreate_timertool), and anything else queued throughdaimon.core.continuity.wakes. A wake is atask_continuationsrow. The Discord, Slack and Teams adapters each run a wake poller (run_wake_poller) that opens threads with due rows and hands them to the adapter's continuation dispatch. That dispatch takes the thread's turn guard and goes throughadmit(),bind_session()andrun_prepared_turn()like a mention, so the balance, cap and channel budget gates apply. A claim holds a lease, andstarted_atis committed just before the turn starts. If a claim's lease expires beforestarted_atis set, the wake is retried. If it expires after, the wake is settledinterruptedand never run again (formal/continuation/WakeLease.tla). Waiting on a busy thread refunds the claim, so it never counts against the crash budget (WAKE_MAX_ATTEMPTS). A thread the adapter cannot open (no token, archived workspace, platform error) is pushed back five minutes, so it cannot hold up other threads. An adapter that starts no poller leaves its wakes pending. The scheduler process has no platform client, so it never runs wakes.
Rollout order. Migration 0028_feat003_wake_queue first, then every
Discord and Slack adapter process, and only then anything that enqueues
wakes (timers). The pre-queue claim and list calls in the store skip rows
whose available_at is in the future. But an adapter binary built before
this change dispatches without leases or fences, and never polls, so its
wakes would only run at a turn tail. Downgrading the migration settles
every scheduled wake that has not run as skipped/downgraded, so none of
them runs early.
Timers add a reason (timer) that older code rejects when it reads a row.
A FEAT-003-only adapter or MCP process fails to load a batch containing a
timer row. So roll timers out in this order: migration 0029_feat084_timers,
then every Discord, Slack and MCP process on a timer-aware build. Only then
may create_timer be called, and it is only exposed by that MCP build.
Downgrading 0029_feat084_timers deletes every timer row, fired or not.
A wake (timer, handoff, applied private input) only runs as the agent it
was queued for; applied private input may also resume the agent of the
requester's live session in the thread. If the thread answers to
another agent when the wake fires, the adapter refuses it after
admit() and before anything is bound or billed. It settles the row
skipped/skip_target_changed and posts a notice in the thread.
If you add another, reuse admit() rather than re-deriving the gate order.
Standalone apps¶
apps/notebook-host/ serves published marimo notebooks, spawning one
marimo subprocess per notebook behind a reverse proxy. Each subprocess has
its own access token, and scratch notebooks are read-only unless the
publisher asks for the editor and the operator allows it
(notebook.allow_editable on the bot and allow_editable on the host). With
DAIMON_NOTEBOOK__ORIGIN_BASE set, each notebook is served from its own origin
(<label>.<origin_base>); the proxy routes by Host and refuses cross-origin
requests and WebSockets. Without it, notebooks share one origin, so a public
host admits uploads only from the tenants the operator lists in
DAIMON_NOTEBOOK__TENANTS, matched against the tenant named in the bot's
signed upload token.
apps/report-host/ serves one published PDF report with a chat sidebar.
Both are FastAPI processes that hold no Anthropic key and no database
credential; they reach daimon over HTTP with capability tokens, and the
must not import daimon contracts keep it that way.
Where to look next¶
- routines.md — the scheduler and headless turns.
- billing.md — metering, the gates, the ledger.
- defaults.md — what is seeded and how reconciliation works.
- mcp-tools.md — every tool the agent can call.
- configuration.md — every setting.
The operator CLI also exposes daimon tenants funding-mode PLATFORM EXTERNAL_ID
MODE. This stores a per-tenant prepaid or operator_funded policy. Shared
balance admission emits a warning instead of a refusal for operator-funded
tenants; usage recording and configured caps continue through the same path.
daimon promo create|list|revoke|redemptions manages deployment-wide promo
codes. Admins redeem them from /billing on Discord and Slack (a Redeem code
button, shown only while a code is redeemable, and a modal), the Teams
billing card (a code field and button, shown the same way) or with the MCP
tool redeem_promo_code; each surface calls
daimon.core.promo_credit.redeem_promo_code. Scheduler housekeeping settles
timed credit windows through daimon.core.promo_settlement. See
billing.md.
Invocation context fragments¶
Core adds a turn_context block before the user message, chosen by the trusted
caller origin (chat, routine, relay, or handoff). Default chat adds nothing.
Scheduler runs use routine framing; Discord and Slack handoff continuations use
handoff framing. Callers drafting a relay pass origin="relay" to the core runner.
The block is included again on dead-session recovery.
Agent YAML accepts context_fragments, keyed by origin, with text and an optional
mode (replace, the default, or extend). For example:
context_fragments:
routine:
mode: extend
text: "Include the source timestamps in the result."
relay:
text: "Write a concise client-ready answer in Spanish."
The spec converter stores this configuration in the agent's system field so it survives upload, forks and defaults fingerprints. Replacing that system field without the configuration removes the overrides. An empty replacement disables a fragment. These blocks affect prompting only and grant no extra permissions.
Operator recovery tools: daimon backup platform-export exports the dedicated
MA workspace through core; scripts/backup/postgres.sh backs up/restores Postgres.
See self-hosting for the recovery
contract and limits.
Google tokens in ordinary chat¶
Chat sessions attach a per-account, per-agent vault whose signed JWT carries
chat_agent_id, derived from the tenant and Managed Agent ID. MCP resolves this
as the executing agent identity for get_cli_token(service="gcloud"), while
preserving the ordinary chat tool surface and live account-role checks. The
separate agent_id claim still selects the restricted external agent-chat surface.
Neither claim is supplied through tool arguments. Chat identity is stored separately
from AuthIdentity.agent_id and is consumed only by the Google broker path. GitHub
chat calls still resolve the account principal-default PAT; other identity gates
and the two-tool search interface remain unchanged.
The operator must configure credentials.google_sa_json, authorize domain-wide
delegation, and bind the agent with daimon agents bind-google <agent> <email>
--scopes <scope>.... The broker impersonates only that agent's bound Workspace
user and scopes; an unbound agent receives a clear operator-binding error.
Core does not ship curated Workspace tools. Agents may use the token themselves
or a deployment-provided Google MCP server.
Existing static-bearer vault credentials are upgraded in place on the next session creation. Credential metadata records the identity version so subsequent creates leave the token stable; unrelated and OAuth credentials are preserved.
Completion signals¶
The core driver calls an optional on_acknowledgment lifecycle hook with
accepted after the initial event send and done after successful answer
delivery. Missing hooks are no-ops; reaction failures are bounded and do not
fail the turn. Opted-in Discord and Slack tenants react with eyes, then a check
mark on success. Unprompted Discord turns stay silent; failures and cancellation
do not get a completion marker. Continuations without a trigger message skip
reactions.
A cancelled prompted Discord or Slack turn keeps any partial answer and appends "Turn cancelled."; a turn cancelled after only tool calls shows that notice in its status card. A cancelled turn sends no completion ping and no feedback controls. An unprompted Discord turn cancelled before it has an answer stays silent.
Set DAIMON_COMPLETION_PINGS to a JSON object keyed by tenant UUID, for example
{"00000000-0000-0000-0000-000000000001": true}, to deliver that tenant's final
answer as a fresh thread reply mentioning only the requester. Missing or false
entries keep the existing in-place answer and reactions (none on Discord; Slack keeps its admission eyes). Slack admission adds eyes once; the lifecycle only replaces it on opted-in completion. Recovery lifecycles retain this policy;
continuity notices and feedback target the new answer. Teams posts the answer fresh (with
an @mention in a channel), then sets its card to "Done. The answer is below."; bots cannot
react there.
Routine dispatch¶
The scheduler owns a persistent, bounded routine dispatcher across ticks. Routine turns run independently of the tick; the same routine cannot overlap itself. Per-routine missed-run policy and the latest skipped range are exposed by the routine MCP tools. See routines.md for catch-up and shutdown.
Agent-initiated direct messages¶
The shared channel tool send_direct_message(recipient_id, content) dispatches
to Discord, Slack or Teams under the authenticated tenant. Both sender and recipient
are checked for current platform membership before a DM is opened (on Teams,
a team daimon is in that both belong to). Discord bot recipients, Slack
inactive, external, or bot users and Teams anonymous members are rejected. Other
platforms return unsupported. Channel tools continue to reject DM channel IDs.
Default recipient policy is tenant members. DAIMON_DIRECT_MESSAGE_POLICIES is
a JSON map keyed by tenant UUID, for example:
{"00000000-0000-0000-0000-000000000001": {"mode": "allowlist", "recipient_ids": ["U123"]}}
Tenant UUID keys are normalized at settings load, including uppercase and unhyphenated UUIDs. Invalid keys fail settings validation.
mode accepts members, allowlist, or disabled. All modes that allow sending
still require live tenant membership. The tool sends at most 19000 characters
as plain text in bounded chunks and returns every platform message ID. Partial
failures state the number already sent; callers should not retry the whole text
blindly. Attachments and cross-tenant delivery are outside this tool's scope.
Memory write policy¶
Session memory mounts are read-only for channels with limited readers (including their threads),
for DMs when the tenant access policy sets dm_memory_read_only, and for routines.
Other chat turns retain writable memory. Admission carries the trusted decision;
the mount mode is recorded in the session snapshot and checked before reuse.
Tightening access replaces an idle writable session, including a legacy session
whose mount can no longer be inspected. An active session refuses the restricted
turn instead of deferring enforcement. Replacement
when tightening memory access skips the old session's checkpoint, since that would execute
with its previous permissions; platform history supplies the new turn's context.
Uncommitted workspace files are not transferred on this restricted replacement.
Memory content is managed directly by the MA memory store. This safeguard does not add per-memory author/origin records or rollback tooling.
Platform table rendering¶
Enable per tenant with DAIMON_TABLE_RENDERING, a JSON map of tenant UUIDs to
booleans. Teams renders Markdown tables natively, so the setting does nothing
there. Missing/false preserves current plain-text delivery. UUID keys are
validated at startup. Core tables.render_tables parses pipe-delimited Markdown tables outside fenced
code and accepts an optional async platform hook. Without a hook the input is
returned unchanged. Both adapter helpers also default to disabled. Hook failures
and oversized tables retain their raw text. Rejected Discord attachment edits
and Slack table blocks are logged and retried as the original Markdown; Slack
keeps already-delivered chunks in place and retries only the rejected table.
The shared bound is 20 columns, 100 rows including the header, and 10000 cell
characters; at most ten tables render per answer.
Discord renders final-answer tables off-thread as PNG attachments using bundled
Inter fonts (including wizard submissions and their recovery turns), navy headers, light alternating rows, and horizontal rules. Wide
cells wrap without truncation, all-numeric body columns align right even without
an explicit ---: marker, and a pixel budget
prevents excessive allocations. Tables containing glyphs absent from the selected
font, including CJK text, remain unchanged Markdown so no values are lost. Table markers preserve their position in the
surrounding answer text. Slack final replies use native wrapped table blocks,
with each table in a separate message so table budgets stay bounded and prose
order is preserved. Feedback stays on the final delivery and continuity notices
can be inserted before a leading table. See the Slack table block reference.
Streaming status previews and MCP send_message remain plain text. Tables inside
code fences remain literal examples. Other adapters need no renderer changes.
Durable turn outcomes¶
turn_outcomes stores one content-free terminal record per logical turn. A UUID
follows admission, session binding and execution; a dead-session recovery remains
one turn. Discord, Slack, their continuation/wizard paths, headless routines and
CLI runs use the same recorder. Admission and concurrency refusals are recorded
even when no model runs. The row contains tenant/account and agent identifiers,
platform/channel/thread identifiers, the shared TerminationReason, UTC start/end
and monotonic duration, recovery status, exception class, package release and
observed usage-event keys. It contains no messages, prompts, answers, tool inputs,
rendered error strings or credentials. Missing attribution stays null (for example,
a CLI run against an existing MA session); no tenant is inferred from user text.
The terminal path schedules a bounded background write and never awaits database
I/O. Inserts are idempotent on the turn UUID. At most 256 writes are pending; each
has a one-second timeout and owns its database connection. Failures and queue
saturation log identifiers and exception class only. Runtime shutdown drains
pending writes before disposing its engine. These are best-effort diagnostics:
process crashes, queue saturation and database outages can lose an outcome. They
are not a transactional audit log, and never change admission, billing or replies.
Library-only headless calls without a session factory remain unrecorded; all
production headless entrypoints provide one. Usage references are the natural
(managed_session_id, event_id) keys, including calls observed during recovery;
billing-exempt calls may have no corresponding usage_events row.
MCP agent-chat and hub ask calls also record one outcome, attributed to tenant,
account, agent and session with platform mcp and origin chat. A returned idle
reply is completed, an observed terminated session is session_terminated, and
the bounded polling deadline is ceiling. Shared admission gates record balance,
cap and access-policy refusals. start_turn and continue_turn record the
accepted dispatch with reason unknown: they return before model execution ends,
so these records are dispatch observations, not claims of terminal completion.
No follow-up terminal update or model-span usage capture is implemented for these
SDK polling paths. Their usage fields remain null rather than implying zero
model calls or cost. Channel/thread identifiers are unavailable on these calls.
Identity resolution failures before tenant attribution and adapter readiness /
draining gates before the turn boundary are outside this coverage. Library-only
headless calls without a session factory remain unrecorded.
The terminal outcome row also carries optional per-turn usage measurements:
model-span token/cache totals, model IDs and estimated provider cost. The driver
observes billed and exempt spans without changing metering; natural
(session_id, event_id) keys deduplicate replay and retain recovery-attempt usage.
The operator command daimon usage turns queries tenant-scoped rows and channel /
origin summaries without upstream requests. See billing
for unknown-cost and historical-row semantics.
Security audit trail¶
Authenticated requests through the main JWT MCP application (tools/call and
tools/list) produce one tenant-scoped
security_audit_events row. The identity middleware records the tenant, account,
platform user and agent identifiers, tool name, timestamp, outcome and a fixed
reason code. Agent identity includes the signed external agent_id or ordinary
chat chat_agent_id claim. A registered token adds its kind and jti, and a
scoped tool adds the operator scope it checked. The shared operation policy annotates that same request with the
operation name and its decision; a policy denial remains a denial even if a tool
catches it. A tool that refuses a call on an access decision (authorize: a channel
or agent rule, the invoker allowlist, a routine or default binding, an
environment pick) records the action as the operation and authz:<reason> as
the reason, so those refusals are denials rather than tool errors; a list that
filters on the same decisions records nothing. Arguments, messages, credentials, response bodies and exception text
are never copied into the row. Tool names outside the supported identifier syntax
are recorded as <invalid>.
The audit transaction is independent of the tool transaction. Request completion
queues an immutable metadata snapshot without awaiting the database. Timestamps
capture request completion, preserving chronology when inserts finish out of order.
The middleware
tracks at most 128 writes, with two database writes in flight and a two-second
bound including queue wait. Shutdown drains tracked writes. Writes are best effort:
timeouts, cancellation, overflow and database failures emit security_audit.write_failed
with an error type, never the error body. An unavailable audit store does not delay
the tool response or change authorization behavior. Tool failures use outcome
error; authorization and policy rejections use denied. When the verifier refuses a
registered token (revoked, expired, a demoted admin, a kind that does not match its
row), it writes a denied row under the token's own tenant with tool name
auth/verify and the refusal as the reason. Other calls rejected before the JWT
verifier establishes a tenant cannot be safely attributed and are outside this trail.
daimon mcp mint-operator-token, revoke-token and set-token-scopes each write a
row (tool name cli/<command>) in the same transaction as their change.
Admin-tier setup and billing panel writes on Discord, Slack and Teams (channel
rules, channel admins, a channel's environment and skills, coding-tool and operator token
mint and revoke, promo redeem) each write their own row through
core/panel_audit.py, allowed or refused, with tool name panel:<op>; the clicker
is named only when they have an account, so privacy erasure can reach the row.
The channel tidy tools (edit_message, delete_message, archive_thread,
delete_thread) also write their own row per message mutation, committed before the
platform call: target_channel_id, target_message_id, an HMAC-SHA256 of the
text replaced (content_hmac, keyed from the first DAIMON_CRYPTO__KEYS key,
else the MCP JWT secret, under its own label) and the turn (turn_ref), never
the text. Those allowed rows are what the tidy limits count; denied rows
count toward a separate hourly cap. MA identity and origin I/O finishes before
locking. Each write takes the tidy per-agent advisory lock, the tenant policy
lock (FOR NO KEY UPDATE), then the target post row (FOR UPDATE). The
separate audit transaction skips the advisory lock already held by its caller.
The policy is loaded and checked under the tenant lock, which stays
held through the platform call (15-second timeout) and ledger completion.
Policy writers, form consume, support (user lock first), handoff and preparation
never acquire the tidy advisory lock or a post row before the tenant lock, so
there is no reverse lock edge. Pool headroom is reserved before checkout.
A refusal adds a denied row; a failed write adds an error row. An uncertain
result retires the target and clears its hash, refusing retries; a definite
platform refusal leaves the ledger intact. Discord delete_thread deletes
only individually recorded own messages and keeps the thread, including human
replies arriving mid-call. Slack also checks, audits and counts each deletion,
and stops with partial progress on refusal. An agent may tidy only posts in
agent_posted_messages under its own agent id, which send_message and
create_thread write at send time. daimon audit prune also removes those
records past the retention, and account erasure clears content_hmac.
The policy remains synchronous and does no I/O outside an MCP audit scope; the
separate hub OAuth applications (/discord/mcp and /slack/mcp, which use
HubIdentityMiddleware) are not audited in this version. This trail is not a complete record of hub tool activity.
Operators can read records using daimon audit list TENANT_UUID --since
2026-09-28T00:00:00Z --json. Use --account ACCOUNT_UUID to include that person's
audit metadata in an operator-assisted privacy export. Results are chronological;
--limit (1–1000) and --offset paginate retained records. Repeat per tenant for
a person active in multiple workspaces. This operator command uses local database
access; it is not an MCP tool and is not exposed to tenant users.
Retention defaults to 90 days (security_audit_retention_days). Operators must
schedule daimon audit prune TENANT_UUID for each tenant, for example daily. The
command removes only that tenant's events older than the configured age; 0
explicitly opts into indefinite retention. Privacy erasure still applies. Include
the schedule and backup expiry in the deployment's published retention policy.
The shared account privacy purge clears account and platform-user identifiers in all of that account's audit tenants, even when no principal remains. Tenant deletion removes its audit rows in the same transaction. PostgreSQL AFTER DELETE triggers on accounts and tenants enforce these same erasure rules even when an older application binary issues the deletion during a rolling upgrade. The triggers restore the previous transaction-local maintenance flag after scoped erasure; rollback restores both data and flag on failure. Install the migration before starting audit-producing services; privacy workers need no coordinated upgrade. Queued writes lock live identity rows: after account deletion they omit personal identifiers, and after tenant deletion they are discarded, so delayed writes cannot restore erased data.
Ordinary UPDATE, DELETE and TRUNCATE remain blocked by a statement trigger. Dedicated store maintenance functions enable a transaction-local GUC only within a savepoint, perform scoped erasure or expiry, then reset the GUC. Rollback also resets it; TRUNCATE is never allowed. This guards accidental application mutation, not a malicious database administrator or SQL caller able to set arbitrary GUCs. Existing privacy panels do not yet deliver a full audit export: operators include the CLI JSON export in the requested bundle.
Operator tokens¶
An operator token lets an external integration call a few MCP tools over
/mcp for one server admin. daimon mcp mint-operator-token mints one, with
one or more scopes, a TTL of 30 days by default and at most 90 and, with
promo:create, an optional --max-issued-usd ceiling in whole cents. Server
admins also mint, list and revoke them from Who answers where on the Discord,
Slack and Teams setup panels (core/panel_operator_tokens.py): tenant scopes
only, never promo:create, 30 days, shown once, and they list and revoke
only tokens within those scopes; the live admin check is
stored as the account's role, as a turn stores it, so the verifier admits it. daimon mcp list-tokens and
revoke-token manage every registered token, and set-token-scopes --jti ...
--scope ... narrows an operator token to the scopes given: it only removes
scopes, so adding one takes a new token. mint-token CLI tokens are
registered and expire too, while older jti-less ones keep working.
| Scope | Tools |
|---|---|
tenant:read |
get_tenant_summary, list_channel_budgets, get_channel_budget, list_channel_admins, list_channel_skills, list_environments |
channels:write |
set_channel_budget, clear_channel_budget, set_agent_default and clear_agent_default (channel defaults only), set_channel_admins, clear_channel_admins, set_channel_rule, set_agent_rule, set_channel_environment and clear_channel_environment (channels only), add_channel_skill, remove_channel_skill |
agents:archive |
archive_channel_copy |
promo:redeem |
redeem_promo_code |
promo:create |
create_promo_code, list_promo_codes, revoke_promo_code (deployment-wide) |
get_tenant_summary lists every channel with a default, a budget, admins or
a rule, and channels[].own_agents_only says whether its readers are own.
Its read is daimon.core.tenant_summary, which daimon channels list
PLATFORM WORKSPACE_ID [--json] prints too, with the same JSON plus each
channel's readers and writers. The MCP tool omits those two keys: no other
MCP or chat path shows every channel's rule. timed_credit lists the live
timed promo credit. set_channel_rule with an operator token acts as that
admin: the copy it may make is an admin's fork, and its rule writes are the
same as the panel's.
Each request, DaimonJWTVerifier reads the token's mcp_tokens row: it must
be unrevoked and unexpired, its account still a server admin by stored role
with a platform user, and its scopes non-empty. The stored role changes only
on the account's next platform turn, so a demoted admin's token keeps working
until then or until it expires; revoke-token is the immediate stop, taking
effect on the token's next request. Scopes come from the row, so narrowing
them applies to the next request. An operator token cannot open a billing
checkout (/billing/checkout answers 403). The identity middleware disables
every tool for the token, enables those tagged scope:<name> for its scopes,
skips the search collapse and limits it to operator_calls_per_minute calls.
Each tool re-checks its scope (tools/_scopes.py). promo:create tools carry
no admin tag and a server baseline hides them from everyone else. A later
channels:write tool joins by taking the tag and the require_scope call;
_scopes.py describes the steps.
Opt-in DM conversations¶
DM conversations are disabled unless the tenant's separate direct_message_policies
row enables them. Admins use /dm enable or /dm disable; the invoker allowlist
still applies through admit(is_dm=True) on every move and every private turn.
Discord uses a live guild-member lookup and Slack checks the current workspace
membership and role. No stored admin role grants access.
/dm in a server/channel moves recent text context into a new private scope. This
selects the workspace explicitly; unselected DMs remain ignored. The scope uses a
normal thread-agent binding, config cascade and per-account session mapping. Each
new selection resets the scope, so a physical Discord DM shared across servers
never contributes the previous server's private history. The live membership check
is bound to that scope to prevent a concurrent selection from changing its authority.
The context is escaped with the existing handoff builder and includes a source link.
/dm refuses in a channel with limited readers or a Discord thread that has
or sits under one, before any history is read: the DM sits outside the rule
and can reach open channels. Admission reports this as source_sealed, and
start_dm refuses it too. Slack's slash command carries no thread, so a Slack
thread with its own rule (channel:thread_ts) is not refused; instead its root
and broadcast replies are dropped from the copied channel history, as the read
tools do. Discord system notices (thread created, pins) are never copied. The DM
row records its source channel and thread and, on Slack, the channel:thread_ts
of every copied message. Every private turn re-checks them against the current
rules. Once any of them has limited readers the DM is quarantined: the turn is refused,
the conversation row (copied context and private history) is deleted, and the
scope's provider sessions are retired and archived, so a fresh /dm is needed.
Rows from before provenance was recorded cannot prove their source open and
are quarantined as soon as the tenant limits any readers.
Turns use the shared billing/session/recovery pipeline. Row claims prevent overlapping
or duplicate DM deliveries. The route retains bounded source context and private
history; privacy preview and deletion include it. Memory restrictions inherited
from the source remain attached to its DM conversation, and current DM policy is
checked on each admission. Session preparation enforces the selected memory mount access.
Slack private turns register their physical DM destination under a random execution
ID carried in a signed JWT in an isolated MA vault. The read guard resolves only
that exact row for the authenticated tenant/account and removes it on completion
or cancellation. Concurrent routine, headless, channel, MCP agent-chat and hub
credentials have no execution grant and cannot inherit account activity.
Each Slack DM turn starts a fresh MA session and vault; bounded source/private
history preserves conversation context, but ephemeral workspace files and personal
OAuth credentials stored only in the shared vault are not transferred. Old execution
tokens cease granting DM reads when their row is removed; expired rows fail closed.
Both platforms stamp private MA sessions with daimon_private_dm. Generic session,
agent-chat and hub lists, transcript reads and mutation/cost tools default-deny
these sessions even to the same account or an admin. Only a verified credential
whose execution ID matches the stamp can access one. Discord retains session
reuse but carries no transcript-browsing grant, so its private sessions are hidden
from all MCP session tools. Ordinary unmarked sessions keep their ownership rules.
Discord session reuse and ordinary shared vaults are unchanged.
The provenance thread ID remains the private scope ID, matching session mapping.
The first version delivers text replies after completion. Attachments, streaming
cards, cancellation controls, and moving Slack thread replies are deferred; Slack's
slash command carries recent channel messages. Run /dm again to reset or select
another channel. Disabling DMs prevents new turns, without cancelling a running turn.