# Architecture

> The two services, the agent loop, the proxy's five jobs, the team sheet, memory, skills, ambient mode, and the sandbox.

Source: https://getlibero.com/docs/architecture/ · Libero v0.7.0 · pre-1.0

---

Libero is a self-hostable, LLM-agnostic AI teammate that lives in Slack channels as a shared agent — one session per channel, not per user — with persistent curated memory, admin-governed tool access, and asynchronous task execution.

The design principle everything else follows from: **the agent is visibly not a user**. It acts only as itself, under admin-provisioned service credentials, through an allowlisted proxy, with every action audited. Channel isolation is a hard admin-defined boundary enforced in code, never model self-restraint.

## The two services

Two deployable services plus per-channel state. The security property the whole design hangs on: **tool credentials live only in the proxy**, and the agent reaches tools only through it. The agent process holds the credentials it cannot function without — the Slack app and bot tokens, and the model provider key — and nothing a team sheet governs.

```
Slack (Socket Mode)
      │
      ▼
┌───────────────────────────────┐      ┌──────────────────────────────┐
│  gateway + agent  (service 1) │      │  tool proxy      (service 2) │
│                               │      │                              │
│  Slack adapter                │      │  team-sheet loader/validator │
│  channel router               │ HTTP │  vault + token store         │
│  (workspace, channel) → sess. │─────▶│  (encrypted, never returned) │
│  context assembler            │ mTLS │  tool allowlist enforcement  │
│  agent loop (BYO model,       │ local│  MCP client pool             │
│    per-channel override)      │ net  │  HITL approval broker ───────┼──▶ approval cards
│  memory curation turn         │      │  budget meter (tokens/calls) │     in Slack
│  skill author + retriever     │      │  egress allowlist            │
│  checklist renderer           │      │  audit writer (append-only)  │
└──────────────┬────────────────┘      └──────────────┬───────────────┘
               │                                      │
               ▼                                      ▼
   channels root (read-only to both)        audit.db (append-only)
   └─ <channel>/channel.toml                          │
        (team sheet, git-managed)                     ▼
                                              sandbox runner
   agent state root (agent writes; proxy      (containerized code exec)
   reads store.db read-only)
   └─ <channel>/
      ├─ store.db     (SQLite+FTS5+sqlite-vec)
      ├─ MEMORY.md       (agent-curated)
      ├─ skills/*.md     (agent-authored)
      └─ proposals/*.md  (merge drafts; the team applies or deletes)

   shared skills root (read-only to the agent;
   the proxy does not mount it at all)
   └─ <name>/         (operator-published; sheets name which channels get which)
      ├─ SKILL.md
      └─ scripts/ references/ assets/   (the Agent Skills layout)
```

**Three roots, and each split is load-bearing.** The obvious layout puts a
channel's whole state in one directory. It cannot: both services mount the
channels directory and it is where the proxy reads its authorization from, so an
agent able to write there could rewrite a `channel.toml` — and the proxy
re-reads the sheet per call, which makes that a compromised agent widening its
own permissions. The channels root stays read-only to both services and
everything the agent writes goes to a root only it writes. `store.db` is the
first thing on that side, `MEMORY.md` joined it in phase 2 for the same reason,
`skills/` joined them in phase 3 — with `proposals/` beside it, which is the
curator's only output and the one directory here the agent writes and never
reads back.

The third root is the **shared skills** directory, added in v0.5.0 and mounted
read-only to the agent alone. It is neither of the first two, and the second
exclusion is the one worth reading twice: the state root is the one directory the
agent *writes*, so a shared skill kept there would be a file a compromised agent
could rewrite — and where a poisoned channel-authored skill costs one channel's
future tasks, a shared skill is read by every channel whose sheet names it. One
writable file poisoning all of them at once is the cross-channel amplification the
per-channel layout exists to prevent. The proxy does not mount it at all, because
a shared skill is text for the model rather than authorization. An unset variable
and an empty directory are both supported deployments.

Since v0.9.0 a shared skill is a **directory** — `<name>/SKILL.md`, the Agent
Skills layout — where a channel's own stays a flat `<name>.md`. The two roots
differ in who writes them and now in shape: an operator vendors a skill at a
pinned SHA and its sidecars come with it, where a model writes a channel's one
file at a time and has no operation that could produce one.

**The proxy reads `store.db`, and only that.** `search_channel_history` is
served by the proxy, so the proxy mounts the agent's state root and opens each
channel's store read-only — a separate opener with `search` and `close` on it
and no way to write, stamp a version, or migrate. It is the one direction across
that line, and it does not weaken the argument above: the hazard is an agent
writing where the proxy reads *authorization*, and the store is neither the
channels root nor authorization. The mount is read-write at the filesystem level
because a SQLite WAL reader creates the `-shm` and `-wal` sidecars; the
read-only-ness is a property of every connection the proxy opens.

The alternative — the proxy calling back into the gateway to run the search —
was rejected. It needs the first inbound listener on the process whose
compromise this design is written to survive, and it does not protect what it
appears to: the proxy legitimately serves every channel, so the gateway has no
independent way to know the proxy is entitled to the one it names. A compromised
proxy reads everything either way, one hop later. Reading the file directly
keeps *one file per channel is the isolation boundary* a structural fact — the
opener closes over one file, there is no channel column, and no operation takes
a channel id — rather than a promise made by the less-trusted process.

The proxy is a separate OS process listening only on localhost/private network with mutual TLS between services. The agent authenticates to the proxy per-channel: one client certificate per channel, subject `CN=channel:<id>`, and that certificate is the only place the proxy reads a channel identity from — never a header, query parameter, or body field, because the process on the other end runs the model and anything the model can influence is not a boundary. Certificates authenticate; team sheets authorize, and the sheet has a narrow say in the first of those: `[channel] certificate_sha256` lists the fingerprints of the certificates allowed to speak for that channel, so a request arriving on a certificate the sheet does not name is answered 401 before any route sees it. There is still no revocation list and no CRL. Retiring a channel is removing its sheet, which removes its permissions on the next call and leaves a stale certificate holding nothing. Revoking a *leaked key* for a channel still in use is dropping one fingerprint from that channel's sheet — the same file, the same review, the same next-call effect, and the channel never stops working. Rotation is the same mechanism run forwards: pin the replacement beside the certificate in service, swap the material, drop the old fingerprint, with no moment when neither is accepted and no restart of either process. The sheet still cannot make a key speak for a *different* channel, because the certificate's `CN=channel:<id>` is what selects which sheet is consulted. The proxy resolves which credentials and tools that channel's team sheet permits. Compromise of the agent process (prompt injection, malicious skill, model misbehavior) yields no tool credentials and only the tool surface that channel's team sheet allows, with every call audited. Those are model-level cases, and none of them reaches certificate selection: which channel a task runs as is derived from the Slack event, not from anything the model produces. Full compromise of the process is wider, because it holds one certificate per channel it serves — the union of those channels' tool surfaces, though still no tool credentials, since none are in that process. What is in it is the Slack app and bot tokens and the model provider key, which the gateway and the loop cannot run without; a leak there lets an attacker speak as the app and spend against the provider, and reaches no tool the proxy guards. See the [security model](https://getlibero.com/docs/security.md#which-secrets-are-where).

## Gateway and channel router

Built over Slack Socket Mode (no inbound ports — good self-host ergonomics). Sessions are keyed on `(team_id, channel_id)` with a per-session async mutex serializing context writes; concurrent mentions in one channel queue rather than interleave. Every inbound message in a provisioned channel is stored with `user_id`, display name, `thread_ts`, and timestamp — the raw `thread_ts`, null for a top-level message, so a thread is recoverable from the store rather than inferred. The context assembler renders attribution (`@alice: ...`) so the model can address the right person; the display name is a snapshot taken when the message was stored, and resolving it is the assembler's rather than the write path's. Long tasks render a single live-updating checklist message in the thread (edit, don't spam). Follow-ups in a thread the agent is active in do not require re-mention.

## Agent loop

A ReAct-style loop over a provider-agnostic completion layer (Anthropic natively; OpenAI, Google, Groq and Ollama through their OpenAI-compatible endpoints; and either of those behind a LiteLLM — one the operator already runs, or the sidecar the compose file can start. The three are supported deployment shapes rather than a default and two fallbacks, and the agent cannot tell which process started a gateway it is pointed at). Per-channel model override comes from the team sheet. Tool definitions are fetched from the proxy at session start — the agent never constructs tool clients itself. Hard caps per task: max tool calls, max wall time, max tokens, all read from the team sheet and enforced in the loop *and* independently in the proxy (defense in depth; the proxy's meter is authoritative).

## Tool proxy

The proxy is the core of the project. It does five things.

**Credential vault and token store.** Tool credentials at rest live in two stores: the vault, which the operator writes and the serving process only reads, and — for OAuth upstreams — a token store only the proxy writes, because an OAuth 2.1 authorization server rotates a refresh token by handing back its successor, a durable credential no operator ever held. Both are specified as stores with a contract — disjoint writer sets, provenance, keying by credential name, freshness, persist-before-use, replace-not-stack, values leaving only as a wrapper with one guarded unwrap — behind a backend seam, with two encrypted files on the proxy's volume under one master key — reaching the proxy as `PROXY_VAULT_KEY` or, since #495, as `PROXY_VAULT_KEY_FILE` naming a file, exactly one of the two — as the default backend and Google Secret Manager or AWS Secrets Manager as managed alternatives an operator selects with one variable — where writer separation becomes an IAM policy, replace-not-stack becomes the provider's versioning with the superseded value destroyed rather than kept, and there is no master key to hold. All three pass one contract suite, and nothing else in this paragraph changes with the choice. The only values the serving process can persist are values an authorization server just issued for an upstream a team sheet already names; it cannot persist an operator-authored secret or read one back out. Refresh-token rotation survives a restart because the successor is persisted before it is used; access tokens are minted into memory and die with the process. Everything else holds for both stores: referenced by name in team sheets, injected into outbound MCP/HTTP calls by the proxy, and never present in any response body, log line, or error message returned to the agent. A redaction pass scrubs known secret values — the minted access token among them — from tool results before they cross back to the agent, closing the "tool echoes its own auth header" leak class. **Where the authorization server speaks it, a token is bound to a key the token store does not hold.** DPoP (RFC 9449) makes each token request and each upstream call carry a proof signed by a key kept in a third store on the same seam — not the token store, which would make the claim vacuous, and not the vault, whose whole design is that the serving process cannot write to it. So theft of the token store plus the master key yields credentials a thief cannot present: a stolen access token offered as a bearer token, offered as DPoP with no proof, or offered with a valid proof from the thief's own key are all refused by an upstream that checks the binding. The sheet decides per upstream in `[mcp_server.auth] dpop`, and the default sends proofs where discovery advertises support and stays on bearer where it does not — so no existing grant changed behaviour, and a grant made before v0.8.0 is a bearer grant until the flow is re-run. The key is one per deployment, minted lazily, never rotated from inside: rotating it kills every live grant, which is an operator act. A grant enters the token store through an operator entrypoint on the proxy process: authorization-code + PKCE, the client identified by a published Client ID Metadata Document, the redirect pasted back from a loopback URI nothing listens on — so the flow needs no browser on the proxy and no network path from the operator's browser to it, and grant material stays keyed by issuer, byte for byte, with a changed issuer failing closed into a re-grant.

**Team-sheet enforcement.** On each call the proxy resolves the channel's team sheet and answers deterministically: is this MCP server allowed for this channel; is this specific tool on the allowlist; does the call require approval; is the budget exhausted. Any "no" is a structured refusal the agent can relay to the user. The model's cooperation is never part of the enforcement path.

Where the call goes is answered by the same sheet, in two places that do not overlap. An MCP call goes to the `url` on the `[[mcp_server]]` block that carried the tool — declaring a destination there is what authorizes it, and the block that authorized the tool is the block the call is dispatched to. The `[egress]` allowlist governs the destinations the sheet does *not* pin: the code-execution sandbox, and anything later that takes a URL as an argument. Keeping them apart is what stops one grant widening the other — a channel can reach the GitHub MCP server without its sandbox reaching the GitHub API. Redirects are not followed, because a redirect target is the one destination neither list names.

**A tool result is a list of content blocks, not a string.** Text, image, audio and an embedded resource each cross as themselves, so a tool whose whole answer is a screenshot stops being second-class. The proxy vouches for every block against the schema the agent will parse it with rather than re-deriving that schema's rules — a block that failed over there would lose the call rather than degrade it — and anything it cannot vouch for, a payload that is not base64 or a block from a newer protocol revision, becomes the sentence naming its type and size that every result used to be. The completion adapter decides again at the far end: what a provider takes natively it relays, and what it does not degrades to that same sentence rather than to base64 inlined in text. One bound is spent over the whole result — `[llm] max_result_chars`, where text pays its characters and a binary part pays its decoded bytes — and the default does not fit a screenshot, so nothing binary reaches a model until an operator raises a number they already tune. A binary payload is also scanned decoded, and a credential found inside one fails the whole result closed rather than being edited out: a replacement inside a PNG is a corrupt image, so there is no repair to make. The audit row records what crossed, split by kind.

**HITL approval broker.** Tools marked `approval = "required"` — and, under the destructive-verb heuristic (delete, drop, transfer, deploy), tools whose sheet entry says nothing either way — cause the proxy to hold the call and mint an **approval ticket**: one call, one ticket, bound to the channel by the client certificate, dead fifteen minutes after it is minted. The gateway renders an Approve once / Deny card in the thread and relays the click to `POST /v1/approvals`.

An approved call runs by **re-submission**: the agent re-sends the call carrying the ticket, and the proxy serves it only if the server, the tool, and the hash of the arguments all match the ticketed call. Approve-then-mutate is a refusal, not a call. The ticket is single-use, and **the team sheet is enforced again at redemption** — so an operator's edit during the hold beats a click that preceded it, and an approval never widens what a channel may call. A sheet refusal does not spend the ticket, so fixing the sheet inside the window does not cost the human a second click.

Tickets live in memory. A restart drops pending approvals, which degrades to expiry: the cards go stale, the calls behind them never run, and nothing is served unapproved. Every decision is recorded in the audit log with the approver's Slack user id — `approved` and `denied` when a human clicks, `expired` when a request first finds a ticket that died undecided.

**What approver identity is worth, stated so nothing here overstates it.** The click is observed by gateway code — a Socket Mode interactive envelope, not model output — and relayed to the proxy over a route the model has no tool for. So the approver recorded in the audit log holds against a **prompt-injected model**, and not against a **compromised agent process**, which could forge a decision. That is the same narrower claim the token meter makes, for the same reason, and the alternative — the proxy reading Slack itself — is rejected above because it makes the proxy the gateway. Say *tool credentials* survive process compromise; approvals survive prompt injection. What a forged decision still cannot do is widen anything: it can only approve a call the sheet already permits, because redemption enforces the sheet again.

**Budget meter.** Token, spend and tool-call accounting per channel per day, authoritative in the proxy. One SQLite file, two tables: tool calls keyed `(channel, UTC day)`, and the four raw token counts keyed `(channel, UTC day, model)` — two tables because a tool call has no model, so one key over all three would force the tool-call counter to invent one. Rollover is implicit, because a new day is a key nothing has written and reads as zero, so it survives a restart and does not happen at process start. A hard limit stops the loop and requires the daily rollover or an admin reset — `node dist/budget.js reset <channel>`, a second process against the same file, which takes effect on the next call without a restart. Ambient mode draws from the same meter. The soft limit is `[budget] warn_at`, a fraction of each hard limit rather than a second pair of numbers — so a sheet cannot name a soft limit above the hard one it belongs to, and raising a hard limit moves the warning with it. Crossing it is not a refusal: the decision carries a warning on the call it serves, the agent relays it into the thread beside that task's answer, and the model is never shown it. Claimed once per channel per day per limit, in the meter's own file so that a restart does not re-arm it and a reset does.

**The meter also answers a question, and the answer decides nothing.** `GET /v1/budget` reports what the gate would say about spending in a channel right now, computed by the same function `/v1/tools/call` uses, so the read and the gate cannot drift apart. It exists because a model completion never traverses the proxy: a background turn that calls no tool met no bound at all, however far over its caps a channel was, and the agent's quiescence sweep, skill-embedding pass and merge curator are three such turns. Each asks before it spends and runs nothing when the answer is no. This is **advisory and not a second enforcement point** — the proxy cannot refuse a completion it never sees, so a compromised agent simply does not ask; what it buys is cost control for an agent that is working correctly, which is the same standing `[ambient]` has. It reads and can write nothing, so being asked cannot record a call or spend the channel's one daily warning.

**Price drift is recorded and never acted on.** Where a deployment reaches models through a LiteLLM — one the operator runs, or the sidecar in the compose file — the gateway prices each call from its own table and reports what it charged. The proxy keeps that figure beside the counts it priced itself, in a second SQLite file that `budget reset` does not touch, and `node dist/drift.js show` puts the two side by side per model so a stale price table is visible before the provider's invoice is. Only calls somebody priced are in it: a direct provider call reports no cost, and a gateway that cannot price a model omits the figure rather than sending zero, so absent and zero stay different statements. The computed side is derived when the operator asks, from the table as it stands then, which is what makes correcting a price make the difference disappear. Nothing enforces on it — the recording route holds a write-only interface, the enforcement module is barred from importing the store, and the command has no exit code for a large difference — because metering on a number a gateway computed would move enforcement out of the proxy.

The three limits rest on different things. `daily_tool_calls` is counted by the proxy from calls it serves, at the moment it commits to serving one, so it holds even under full compromise of the agent process. `daily_tokens` and `daily_usd` are counted from a report the agent POSTs to `/v1/spend` after each turn — bound to a channel by the client certificate exactly as a tool call is, idempotent on a per-turn id so a retry cannot double-count. The numbers come out of the provider's HTTP response envelope rather than from anything the model writes, so a prompt-injected model cannot forge them; a compromised agent process could, which is an assumption the [security model](https://getlibero.com/docs/security.md) already states and whose consequences are larger.

That report **carries nothing that selects a policy**. It names the model the provider served, which is a dimension of the count rather than a permission: it decides which row the tokens are filed under, the way the day already does, and it selects a price and nothing else. The price table and the cap are the proxy's, and a report naming a model the table does not price refuses the channel rather than metering it at zero — so the lie that helps an agent most, naming no model at all, is the one that stops it, and naming a *cheaper* model buys only what under-reporting the counts already buys. The line to hold as this grows: a field on that report may select a price, and may never select a permission.

What a cached token is worth against `daily_tokens` is a team sheet setting, and what a token *costs* is the proxy's price table, so the meter stores raw counts per model and both the weighting and the price resolve with the rest of policy at decision time. A corrected price re-prices spend already recorded today, on the channel's next call — which is why cost is computed rather than accumulated: a price table is operator-authored config and will eventually contain a typo, and under a stored total the only remedy would be a reset that also discards the spend that was right.

**The report route makes no authorization decision**, and that is structural rather than incidental: it resolves no team sheet, shares no handler with the route that does, and lives in a module with no import that could reach one — a lint rule in CI, not a comment, is what keeps it that way. Reporting spend is not asking for anything, so there is nothing to decide.

**Audit writer.** Append-only SQLite table (WAL), one row per decided tool call: timestamp, channel, requesting user, task id, tool, server, argument hash, outcome, refusal reason, result size and error flag, approver if any, the approval ticket if the call passed through the broker, and the two hashes that chain the row to the one before it. The outcome is one of `ran`, `held`, `refused`, `unavailable`, `unanswered`, `approved`, `denied`, or `expired` — the last three are decisions rather than calls, written when a human clicks or when a request first observes a ticket that died undecided. `unanswered` is the proxy describing itself: the call was decided and metered, the handler then failed, and the agent got a 500 rather than any answer. It asserts nothing about whether the upstream acted, because the proxy could not find out — so `ran` undercounts upstream effects by exactly the `unanswered` rows. A held call and its decision are two rows, because the table refuses UPDATE; the ticket column is what ties them together. Beside the log sits the **attempt store**: the full arguments of every blocked call — refused captured at refusal, held at mint — in a separate, non-chained, deletable file keyed by the row's own argument hash. It stores raw and claims no redaction (its content is model-authored; treat it as hostile), reads re-verify content against the hash the chained row committed to, and deleting a record degrades its rows to hash-only without touching the chain — which is what makes storing hostile bytes survivable: a captured secret is removed by deleting the record, not by rotating the log. The read path is `node dist/audit.js` — query and CSV export, opened read-only — and it is a second entrypoint of the proxy process rather than a command in the published CLI, for the reason the budget reset is one: the file lives in a container volume the operator's host cannot see. The line is that the CLI owns what an operator authors on the host (channels, certificates, configuration) and the proxy's own entrypoints own what the services own inside their volumes.

Append-only is enforced by `BEFORE UPDATE` and `BEFORE DELETE` triggers on the table that `RAISE(ABORT)`. SQLite has neither roles nor grants, so a per-role permission is not available to implement — the triggers are, and they hold for every connection that opens the file rather than only for the service. The write-only interface the proxy holds and the file's permissions are defence in depth around that, not the mechanism. None of it stops an attacker who holds the file from dropping the table or replacing it: append-only means the service cannot rewrite history in normal operation.

**Tamper evidence is the chain, and it is a different job.** Every row carries the previous row's hash and its own — SHA-256 over the predecessor's hash and a canonical serialization of the row's own columns, the first row chaining from a stated constant. Recomputing the walk detects any row rewritten, deleted, or inserted without recomputing every hash after it, which is what editing the file through `sqlite3` does. The serialization is pinned and versions with the schema: a change to it does not migrate a file, it invalidates one. A unique index on the previous-row hash means a second writer, or anyone appending to the file behind the proxy's back, takes the tip's successor slot and the proxy's next call is refused rather than quietly forking the chain.

The limits are worth stating exactly, because "tamper-evident" invites more than it holds. The chain is **unkeyed**, so an attacker holding the file can rewrite a row and re-derive every hash after it, and truncation from the tail leaves a shorter chain that is internally perfect. Both are answered the same way and only that way: `node dist/audit.js verify` walks the chain and prints the tip hash, and anchoring that somewhere the attacker does not hold is what makes the file evidence. It exits 0 when the chain holds, 3 when it is broken with the first bad row named, and 1 when the log could not be read — three outcomes rather than two because a broken chain is a finding rather than a failure, and something running on a timer has to tell them apart. A key was rejected because reading the file means being on the host where the key would be, and because an unkeyed chain is checkable by anyone holding an archived copy — for an audit log, the feature rather than the weakness. The chain also fixes the order of rows and not their numbering, and it is per file, so rotation starts a new one.

There is no retention command and there will not be a delete-based one; when the file needs to shrink, it rotates.

**The arguments themselves are not stored, only their hash, and that is now a decision rather than a deferral.** Capture behind a flag, redacted before the row was written, was designed and declined. Three reasons, and the first is on its own sufficient. Redaction is a scan for a value, so it is a backstop against an upstream that *echoes* a credential and not a boundary against one that transforms it — but the threat capture exists to investigate is a prompt-injected model putting a secret into a tool call, which is an adversary rather than a careless service, so the mechanism is weakest exactly where it would be relied on. The redaction set that looked plausible — every credential the channel's sheet names — has to *acquire* those credentials to get their values, and acquiring an OAuth credential is a token-endpoint round trip, so a refused call would make network requests to mint tokens purely to have something to redact against. And since rows are hash-chained, a secret that did land in a captured argument could not be removed afterwards without breaking the chain from that row onward, so the remedy would be rotating the credential *and* the log.

Incomplete redaction on a durable row is worse than storing nothing, because a column labelled redacted gets believed. The hash answers whether two calls were the same, which is what it was chosen to answer.

**There is no per-call token count, because there is no such quantity.** Tokens are spent by model turns, not by tool calls; the meter records the real numbers per turn. The audit row carries the size of the result the proxy handed back, which it observes directly and which is the largest driver of the *next* turn's input tokens. Handed back is the operative word: where a result was truncated at the channel's `max_result_chars`, the number recorded is the truncated one, because it exists to predict what the next turn will read rather than to describe what the upstream sent. To ask what a request cost, join on the task id: turn ids are `<task>.<n>`.

The same is true of money, and the audit row says so in its wording rather than leaving it to be inferred. A row carries the channel's **spend so far that day** as the decision saw it, in integer micro-USD, together with the digest of the price table that computed it — the figure the comparison was made against and what priced it, so a past budget decision can be re-derived once prices have moved on. It is absent, never zero, whenever nothing was priced: a channel that sets no `daily_usd` consults no table, and spend the table cannot price has no total. A budget refusal also records which of the three limits bound, which is what lets the audit CLI print the sentence the channel was given rather than "the budget ran out".

## The team sheet

The manifest is the admin surface: a TOML file per channel, intended to live in the operator's own git repo. We call it the channel's **team sheet** — the sheet the manager submits before a match declaring who is allowed on the pitch, what position they play, and what needs the gaffer's sign-off. Nothing in it is a secret — credentials are named references resolved only inside the proxy. See the [team sheet reference](https://getlibero.com/docs/team-sheet.md) for a documented starter.

Team-sheet changes are picked up on file change (watched and validated against the zod schema in `@getlibero/schema`); invalid sheets are rejected loudly and the previous valid version stays active.

## Memory

One SQLite database per channel, and the file-per-channel layout *is* the isolation boundary — there is no query path that can join across channels.

- **Layer 1:** full message history with FTS5 for "what did we decide about X" search, exposed to the agent as a `search_channel_history` built-in (proxied like everything else). **A built-in is not a bypass**: it is granted by a `[[builtin]]` block in the channel's team sheet, refused when the sheet omits it, held when the sheet asks for a click, charged to the channel's daily meter, and written to the audit log under the reserved server name `libero`. The only thing that differs from an MCP tool is where the call goes once all of that has passed. Its scope is the calling channel and there is no argument for naming another — the channel comes from the client certificate, and the tool's input schema has no field for one.

  **The calling thread is left out of what it returns, and a query no message holds every word of widens from AND to OR.** Both were measured on a live deployment rather than reasoned about. Inside a thread the prompt is thread-scoped, so this tool is the only path to the rest of the channel — and what it answered instead was the model's own question, which is a row in the store by the time the search runs and shares every word with the query written out of it. The thread is asserted by the agent process on the call, which is a third kind of asserted field: nothing decides on it and no audit row holds it, and asserting it can only *narrow* an answer the calling channel was already entitled to.

  **The agent's own replies are stored and are not searchable.** They live in a table with no full-text index, so what keeps a poisoned reply out of a search result is the shape of the file rather than a filter — a reply is derived from tool results, and a searchable one would give an injection that surfaced in its prose a second life in the channel's own durable state. What they are for is thread context: a follow-up is a reply to an answer, and until v0.8 the model no longer had it.
- **Layer 2:** `MEMORY.md`, agent-curated via a post-reply inner-loop turn: the model gets one extra call with `memory_append` / `memory_replace` tools and instructions to persist only durable team facts. Writes go through the memory package: an operation that would take the file past the channel's `[memory] max_file_chars` is refused and nothing is written, never silently truncated, because a shortened memory is a fact the team believes it recorded. Every write lands by renaming a fully written temporary file over the old one, so a reader gets the old file or the new one and never a torn one. **There is no lock file**: the agent process is the only writer, its per-channel session queue serializes tasks, and the write itself is synchronous with no point at which a second operation could interleave — and a lock that outlives a killed process is a worse failure than the one it would prevent.
- **Layer 3:** semantic recall via sqlite-vec embeddings over curated facts and thread summaries — same database file, same isolation. Summaries are produced by a pass over threads that have gone **quiet**, which is a correctness condition rather than politeness: a thread summarized mid-argument records a conclusion the team had not reached, and that artifact is then retrieved by exactly the question it is worst at answering. Quiet is `[memory] summarize_after_idle_minutes`, and the pass was the first completion in the deployment that did not follow a mention — the merge curator is the second — a channel opts out with `[memory] summarize = false`, and an unreadable sheet falls back to off. What the pass records is shaped by what the thread produced — a question answered, a decision, an incident, an open question, or **nothing at all**, which writes no vector. That last is load-bearing: a corpus is bounded as much by what it keeps out as by what it holds, and a summary of "deploying now" is a vector that dilutes every deployment question near it.

  **Recall enters a task as context, not as a tool.** At the head of every task the agent embeds the incoming request and renders the nearest summaries into the opening context, beside the transcript and `MEMORY.md`. It is not a second `search_channel_history`: a model-invoked read of a channel's content is a proxied built-in, granted by the sheet and written to the audit log, and an agent-local twin of it would route around that decision rather than extend it. Assembling a task's own opening context is a different act and already the agent's — bounded by `[llm]` and by no `[[builtin]]` grant. If mid-task semantic recall is ever wanted, the consistent shape is a vector leg on the existing built-in rather than a second tool. No model-provider key moves in either case: the query is embedded on the agent side, and the proxy holds none.

Slack retention is respected, the agent's own replies included: a message deleted in Slack is deleted from the store on the corresponding event, and derived data goes with it — an edit or a deletion drops the thread's summary and that summary's embedding, so nothing outlives the words it was drawn from. Curated facts in `MEMORY.md` are the stated exception, since curation is a model turn rather than a join and a fact carries no per-message provenance; they are a distillation the team reads and edits as text. Skills and the merge proposals that quote them are the same exception for the same reason — a proposal holds no text that is not already in `skills/`, and a team that wants one gone deletes the file, which is also how they decline it.

## Skills

After any task exceeding a tool-call threshold (default 5), a skill-author turn decides whether a reusable playbook emerged and, if so, writes a frontmatter-structured `skills/*.md` (name, description, created, status). Loading is by retrieval: at task start the agent embeds the incoming request and retrieves top-k matching skills (sqlite-vec + FTS hybrid), loading only those into context — never the whole library. Lifecycle: stale at 30 days unused and archived at 90 — `[skills] stale_after_days` and `archive_after_days`, tunable per channel — run by a maintenance job that makes no model call and spends nothing, plus a curator pass that proposes merges of overlapping skills for human review rather than silently rewriting institutional knowledge. Skills are text in the channel's directory under the agent state root, beside `MEMORY.md` — the root the agent writes, not the channels root the proxy reads its authorization from: reviewable, editable, deletable by the team that owns them.

**Shared skills are the operator's half of the same library** (v0.5.0). An operator publishes a playbook once into the third root — through git, vendored into their own repository, so an update is a reviewed diff rather than text that changed under the model overnight — and each channel's team sheet names which of them it gets, with `[[shared_skill]]`. One canonical file; the sheet is the scope, so a file nobody names reaches nobody. Since v0.9.0 the root takes the Agent Skills layout, `<name>/SKILL.md`, so a marketplace skill is copied in rather than flattened; `metadata:` and any key the format does not define are read and kept, and `allowed-tools` is read and dropped, because the team sheet is the allowlist.

Two load modes, because retrieval cannot serve the consistency case. `load = "always"` puts a playbook in the system prompt of every task in that channel — what a house voice needs, since retrieval will never surface `brand-voice` for a database migration — bounded by `max_always_skills` and `max_always_chars`. `load = "retrieved"` joins the channel's own retrieval pool, bounded by `top_k` and `max_skill_chars` exactly as the channel's own are; `top_k` bounds the whole pool rather than either half. `[skills] enabled = false` does not switch either off: that switch governs what a channel grows for itself, and these were decreed rather than grown.

An always-loaded playbook is part of the agent's **standing region** — the base prompt, the sheet's `[channel] description`, its `[channel] persona`, and the always-loaded shared skills, composed in one place. Five turns compose it, and the line between them and the rest is composition against record: a turn that composes something a person will read or a playbook the team will keep gets it, because an operator's standing text is guidance for how that thing should read. So the task reply, both kinds of proactive post, the skill-author turn and the merge curator all carry it — house rules about how a runbook is written belong where a runbook is written. `MEMORY.md` curation and thread summarization do not: those keep a record, and standing text there is either noise or a thumb on the scale.

The sheet has no way to say which turns a shared skill applies to, so a voice skill and an authoring-standards skill are indistinguishable and both reach all five. That is a stated cost rather than one worked around.

Addressed as `shared/<name>` wherever the agent refers to one, so a shared playbook and a channel's own of the same name never collide — `/` is not a character a channel-grown name may contain. The model is told which library it is reading: shared skills render under `<shared-skills>` and the channel's own under `<channel-skills>`.

**They do not age, and the model has no verb over them.** The lifecycle clocks and the merge curator are scoped to the channel's own half, so a decreed playbook stays until the sheet or the file drops it; uses are recorded and no clock acts on them. Nothing in the agent can write the shared root — there is no operation that names it, and in the deployment it is bind-mounted read-only. A **marketplace mechanism** — runtime fetch, discovery surface, auto-update — was declined rather than deferred: auto-updating text that enters a model's context is an injection subscription, a runtime marketplace client is a new egress surface, and retrieval over content optimized to be retrieved is a contest the grown-only corpus does not have.

The trust claim, written narrowly: a sheet-named shared skill is operator-authored through git, so it survives a prompt-injected model and does not survive a compromised operator repository — which was already true of the team sheets themselves. What holds regardless is containment: a hostile shared skill widens nothing, because every call it induces meets the same gates, in the same order, as if the same words had arrived in a mention.

**The file carries what a human authored; the index carries what the runtime observed.** Use counts and last-used timestamps are columns in `store.db`, not frontmatter — an earlier draft of this page listed `uses` among the frontmatter fields, and #289 moved it. Retrieval records a use at the head of every task, for every skill it loaded, so in frontmatter that would be top-k rewrites of team-owned markdown per task, each one able to lose an edit somebody made in between. The rule that leaves: reconciliation reads these files and never writes them, and `created` is documentation — no clock reads it, because it is a model-authored line in a file the team may edit. What the clocks run on is the index's own record of when it first saw a skill and when a task last loaded one.

**The curator proposes and never applies, and where the proposal goes was forced rather than chosen.** A merge lands in `proposals/<a>--<b>.md` beside `skills/` — a markdown file showing the merged playbook as a complete file, both originals beside it, and the two acts that apply it: replace one skill file, delete the other. The obvious surface is the channel, and this process cannot reach it: `postThreadReply` is deliberately withheld from the composing app so a handler cannot post out of band, and a card needs a `threadTs` from an inbound event that a background pass does not have. A proactive post is ambient mode's mechanic, and since #320 it is wired: the heartbeat names a waiting proposal in a channel — the file and the two acts, once, and none of the document — while the file stays the thing a person reads and deletes. The notice is composed from the two skill names rather than by a model, which is what keeps closed the path by which text in `proposals/` would re-enter a model's context. Approval cards are separately not it: a card is the *proxy's* mechanic for a held tool call, and this is not a tool call.

Three rules make it a proposal rather than a queue. **Nomination is the index's job and the model only drafts**: a pair is a candidate when each is the other's nearest skill by vector — mutual nearest neighbour, which needs no distance threshold, where "the closest pair not yet seen" would grind through every pair in the library one model call at a time. **The merged skill keeps one of the two names**, so its use counts and the date it first appeared survive; a third name would reset both clocks. And **declining is deleting the file**, which nothing observes — a pair is raised once and not again until one of the two descriptions changes, so ignoring and declining are the same act, and three unread proposals stop the pass making more. A deployment with no embedding provider proposes nothing at all: unlike retrieval, which falls back to full text, there is no lexical answer to whether two playbooks are near each other.

**The job was written as a pass on channel activity rather than a weekly cron, and the two are the same thing here.** The clocks are absolute dates, so the job is idempotent: running it more often moves nothing sooner than its threshold and running it less often only delays. "Weekly" is a statement about how often a status needs revisiting, and any interval at or below it satisfies that — where a cron would mean the process growing a timer and an enumerator over every channel, neither of which anything else here needs. A channel nobody has spoken in for a year ages nothing until somebody does, which is the same answer the quiescence sweep already gives. Two rules keep the team in charge of their own files: a status the job did not write is adopted rather than overwritten, and adopting restarts the clock, so a hand edit buys a full stale window before the job has an opinion again. Ageing needs only time; moving a skill back toward `active` needs a task to have loaded it — which is what makes `archived` terminal without a rule saying so, since nothing archived is ever loaded.

## Ambient mode

Ships last, disabled by default, and only behind the budget meter. A per-channel schedule drives a heartbeat evaluation: recent activity is summarized and the model is asked whether anything merits a proactive post — a stale thread, an approaching deadline, an unanswered question — with a SILENT sentinel otherwise. A `schedule_task` tool (proxied, audited, approval-gated by default) lets the agent create its own future checks. Since v0.6.0 a third source sits beside those two: `[[ambient.rule]]`, an operator-authored recurrence at a clock time.

**`schedule_task` takes an offset and stores an instant, and the split is the whole shape.** "In two hours" is the model's phrasing, and a model has no clock — so what it sends is `due_in_minutes`, an integer with its unit in the name like every other duration on a team sheet, and the proxy resolves it against its own clock when it serves the create. What is stored is absolute, so a fired task does no arithmetic; what crosses the wire needs no timezone, no date grammar in a package the CLI publishes with no dependencies, and no trust in arithmetic done in prose. Recurrence is not expressible *on this tool*, and deliberately so: a fired check makes no tool calls, so it cannot create its own successor, and a repeating schedule must not become a loop the scheduler owns. Recurrence exists instead as a team-sheet rule, below — which is the same answer from the other direction, since every hard question about a standing action turns out to be a question about authority. Chaining from a fired check remains [#348](https://github.com/getlibero/libero/issues/348)'s open question.

**The create is governed in the proxy and the ticket is recorded by the agent, and that split is forced.** The proxy opens a channel's store read-only — the isolation rule, one direction across the line — so it cannot write the row a scheduler would fire from, and giving it a writer would put a second writer on one file from the process that must not be able to repair a channel's evidence. So a served create returns the ticket it minted and the agent side stores it. What follows is the honest shape of the pending cap: the proxy counts what the agent wrote, which is exact against a prompt-injected model — a task's tool calls are dispatched one at a time, the write is synchronous, and a channel's work is serialized on one mutex, so no burst gets past the count — and is no claim at all against a compromised agent process, which could write rows nobody approved and has cheaper attacks available. The two can disagree in exactly one direction, an audited create whose row never landed, and the model is told so rather than left reporting a check that will never run.

**Its bounds are architecture constants, and approval-gated by default is declared rather than guessed.** How many checks may be pending per channel, how far out one may be scheduled, how soon, and how long the question may be are constants beside the shape — the `RECALL_LIMIT` test, which asks who grew the corpus: these tickets are machine-grown, so bounding them bounds what this process assembles rather than stating a policy a team authored. Each has its own refusal in the closed set, so a model that asks for more is told which bound it met. And the default hold is a declared property of the built-in rather than the destructive-verb heuristic's answer: that heuristic exists because upstream tool names were chosen by somebody else, where a built-in's name was chosen here. So a sheet must write `approval = "none"` to loosen scheduling, and forgetting the line gets the hold. One further precondition: a create is refused outright on a channel whose `[ambient]` block is off, because nothing would ever run the check and a channel accumulating approved future work no clock will enumerate is worse than a refusal.

**The heartbeat's schedule is an interval, and there are no quiet hours.** `[ambient] heartbeat_every_minutes` — a number, not a cron expression, and therefore not a grammar: every duration on the team sheet is an integer with its unit in the field name, and an interval has nothing more to say. Cron would buy sleeping hours and workday alignment, and the paragraph below gives the first away for free, since a tick with nothing to weigh is silent and spends nothing. The example sheet carried a cron string from the first commit; nothing ever validated it and nothing ever read it.

That argument is about the *heartbeat*, and it does not carry to rules. What it says is that an interval has nothing more to say, and a rule has more to say, because a rule **speaks** at its instant rather than looking at it: an 03:00 heartbeat is free, and an 03:00 digest is a post at 03:00. So the two live in one block without contradicting each other — a cadence for the noticing job, clock times for the standing ones.

**Recurrence is an operator-authored rule, because every hard question about it is a question about authority.** `[[ambient.rule]]` says: at these times, on these days, ask this question. The sheet answers each question a recurrence raises without machinery — the caps are sanity bounds because rules are human-grown, the approval is the reviewed edit that added the entry, and a prompt-injected model cannot plant one because the model has no write path to that file. `schedule_task` had to answer all three with mechanism; this answers them by being in the sheet. The model gets no verb that plants a standing action, and a user asking in-channel for a weekly reminder is pointed at the sheet.

**Structured fields rather than a cron string, and the cadence floor is why.** `at` is a capped list of `"HH:MM"` and `days` an enum list, so a flood is impossible by arithmetic over two list lengths — at most four times per rule and eight rules per sheet, so 32 posts a day at the very most. `*/5 * * * *` is exactly what this has to forbid, and forbidding it in a string means parsing the expression and computing its minimum firing interval, which is a rule somebody has to write correctly rather than a shape the schema has. Fields also have no dialects — five fields or six, `@weekly` aliases, Vixie versus Quartz step semantics — where the failure mode is a string that parses under one reading and fires at the wrong time, silently. The example sheet shows each rule beside its cron equivalent in a comment, which is what familiarity was actually worth.

**Times are read in the rule's own zone, and absent means UTC** — so a rule written before `timezone` existed means what it always meant. The half of the original objection that mattered turned out not to hold: the DST-correct arithmetic is the server's rather than the schema's, and Node's built-in `Intl` does it with no dependency, so the CLI's dependency-free bundle was never what stood in the way. The schema still only validates, and validates against the runtime's own canonical zone list rather than a pattern — which is what refuses a fixed offset like `+01:00`, a "zone" that never springs forward and would silently give an operator permanent summer time. The two days a year a wall clock is not a function of an instant are decided rather than left to the arithmetic: a time the zone skips does not fire that day, which is the skip-don't-replay rule applied to a window the day did not contain; a time it repeats fires once.

**Every rule is an ask, and the deterministic kind was declined rather than deferred.** There is no field for text to repeat verbatim: replaying fixed text on a clock is what Slack's own reminders already do, and what a rule buys instead is an answer composed from the channel's state at the moment it fires. A rule fires the same bounded turn a scheduled check does — one turn, one post, no tool client — so the containment claim holds for it by construction rather than by a second implementation remembering to.

**A missed occurrence is skipped, never replayed, and never fired late.** The next occurrence is computed from the wall clock at first sight, so there is no last-fired stamp to get out of step, a restart cannot double-fire, and nothing has to be persisted. The cost is stated rather than discovered: a restart spanning Monday 09:00 loses that Monday's digest. That is where a rule differs from a check — a check fires once late because a person approved that particular instant, and a rule is standing, so firing Monday's digest on Tuesday would answer about the wrong day under a label saying otherwise.

**A capped channel's rule follows the fired check's precedent**: it fires, spends nothing, and says once — in its one post — that it could not run, naming the rule so an operator can find it in the sheet. Where a check's notice says the timer is spent, a rule's says the rule still stands and will run again, because it will. `[ambient] enabled = false` is still the one silence; `[ambient] heartbeat = false` stops the evaluation and leaves the rules firing, which is the channel that wants Monday digests and no noticing job.

**A question is not unanswered until it has sat.** Sampled at an instant, "unanswered" is meaningless: a question typed thirty seconds before the tick looks identical to one the team has ignored for an hour, and answering the first front-runs the teammates it was addressed to. So the heartbeat considers a question only once it has gone `[ambient] answer_after_idle_minutes` without a reply — the knob beside `heartbeat_every_minutes`, a sibling of `[memory] summarize_after_idle_minutes` in name and in kind, because both state the same rule: acting on content before it has gone quiet says something the moment hasn't earned. The two knobs answer different questions — the threshold is what counts as unanswered, the schedule is how often anyone looks — and the worst case for a proactive answer is their sum. A team that wants the answer now tags the bot, which costs one word and is the designed path.

**Silence is calling no tool, not a sentinel.** The paragraph above says "with a SILENT sentinel otherwise", and the implementation diverges: the heartbeat is offered one tool and saying nothing is calling it not at all. That is the idiom every other background turn in this tree already uses, and under it the requirement that follows — an answer which is neither the sentinel nor a postable finding is treated as silent — holds by construction rather than by a rule somebody has to write correctly. A malformed call, an invented tool name and a paragraph of prose all produce no finding. A sentinel would have to be recognized, and "when unsure, post" is the wrong default for an agent speaking to a channel that did not ask.

**A tick with nothing to evaluate spends nothing.** Before any model call, the heartbeat decides deterministically whether there is material: nothing new since the last evaluated position, no question newly past the answer threshold, nothing else due — then the tick is SILENT by construction and no completion is spent. The lifecycle job's discipline again: the model is consulted when there is something to weigh, not on a clock's say-so. This is what lets a schedule be brisk without the meter noticing, because a channel's quiet hours cost nothing.

**A finding is offered at most once per silence, and a shut window defers rather than loses.** The heartbeat keeps a per-channel watermark — the newest message it has already weighed — and advances it whenever an evaluation runs, silence included. That is what stops the same unanswered question being raised every window: the heartbeat reads the store's one-sided view — what people said — so nothing there records that it already spoke. The agent's own replies are stored as of v0.8, in a table that read does not see. And the rate window is consulted *before* the evaluation rather than after it, so a heartbeat that could not post does not evaluate, does not move its watermark, and weighs the same material again once the window opens. Evaluating first would have forced a choice between losing the finding and paying for the same turn on every tick until the window opened.

**A fired check runs once, and says so when it could not.** One firing, one outcome: it posts an answer, it runs and has nothing to say, or the channel is told — in that same one post — that the check did not happen, because the channel is over its budget or because it could not be run at all. There is no queue and no retry, and that is a decision rather than a simplification. A due check that stayed pending would keep asking a loop that sleeps until the next due instant to wake at an instant already past, so it needs a backoff, which needs a retry stamp, which needs a staleness rule — and at the end of that a reminder can arrive days late, which is worse than not arriving. Telling the team keeps what the queue was protecting: they can act on the timer even when the agent could not do its part. `[ambient]` off is the one silence, because that switch means *do not speak here* and a notice would be the agent speaking after being told not to.

**An unattended turn makes no tool calls unless a channel asked for it** (v0.6.0, [#348](https://github.com/getlibero/libero/issues/348) for fired checks and rules, [#471](https://github.com/getlibero/libero/issues/471) for the heartbeat — one switch, because a channel decides this once). Left alone it is one bounded turn over the channel's recent messages with a single tool that posts, and no tool proxy client at all — so "every call a fired task induces meets the same gates a mention's does" is true by there being none. A channel that writes `[ambient] tools = true` gets the ReAct loop instead, over the allowlist its sheet already carries; the switch decides who may use that list rather than what is on it, and it defaults off so that no sheet gained an unattended caller by upgrading.

That widening was a decision taken on purpose, and its two hard questions resolved against machinery that already existed. **An approval card with nobody to click it** is answered by not offering one: an unattended turn is handed no prompter, so a held call comes back to the model as the refusal it already is — and because a destructive *name* is held by default, the line that draws is read-yes-write-no without anything having to decide what destructive means. **A pending cap sized against a cheaper unit of work** is answered by the cap ceasing to be the bound: `daily_tool_calls` becomes it, and that one is counted by the proxy from calls it served, so it holds against a compromised agent process where the pending cap does not. What no unattended call carries is a person: every one is attributed to a reserved sentinel that no user id can spell, so the audit log says plainly that a clock asked.

**A scheduled task fires at its time, not at the next tick.** The scheduler — this process's first clock, and its only one — sleeps until whichever comes first: the next scheduled tick or the next due task. A reminder asked for ten minutes before standup arrives then, not up to an interval late; the heartbeat's cadence is chosen for the noticing job, which tolerates minutes of slack and hits no deadline. A wake for a due task runs that task's check and nothing else — it is not a heartbeat. A task that came due while the process was down fires once, late — absolute time, the lifecycle clocks' argument — never once per missed interval.

**The rate limit is stated in time, not in ticks.** At most one heartbeat-initiated proactive post per channel per rate window — a fixed duration enforced deterministically in the posting surface, an architecture constant rather than a sheet knob, so tightening a schedule cannot quietly loosen the throttle. The window governs unbidden speech, and that is its whole jurisdiction: a fired task's post is bidden — its creation was governed and, by default, human-approved — so it is bounded by its own shape instead, one post per firing and one firing per task, with flooding refused at the capped, gated create. A reminder is not late because the heartbeat spoke first.

## Sandbox

The built-in `run_code` tool runs model-written code in an ephemeral container: a read-only rootfs, a tmpfs workdir, cpu/memory/wall-time caps the channel sets, and **no network at all** unless the team sheet grants an egress allowlist. The container is gone when the call returns. The runtime is whatever the host's daemon defaults to, so gVisor is a deployment choice — see [self-hosting](https://getlibero.com/docs/self-hosting.md) for what that costs and what is untested.

It is a built-in, so it is granted by a `[[builtin]]` block, refused when the sheet omits it, held for a human by default, metered, and audited under the reserved server name — the same path every other tool takes. The proxy invokes it; the agent never does.

**The proxy does not hold the container runtime.** A separate runner service holds the Docker socket — which is equivalent to root on the host — and holds no credential at all, so the process with the privilege and the process with the secrets are different ones. The proxy reaches it over mutual TLS on an internal network the agent has no route to, and the request it sends has no field that names an image, a mount, or a capability: the runner builds every container spec itself.

**It is opt-in twice.** A channel's sheet must grant the built-in, *and* the operator must have started a runner. A deployment that did neither is unchanged, and a channel that asks for `run_code` where no runner exists is told the call is permitted and this proxy has nothing to serve it — not that it was denied.

## Threat model

See the [security model](https://getlibero.com/docs/security.md).

## Scope (v1 non-goals)

Discord/Teams adapters (the gateway supports them in principle; none is planned — a second surface is worth building when a real team asks for it). A web admin UI — manifests are files in a git repo, and that *is* the admin UI for v1. Fine-grained per-user permissions within a channel — channel membership is the permission boundary. Voice or DM personal-assistant modes. Multi-workspace control plane — single-tenant self-host only.

## Acknowledgments

Libero builds on and learns from prior open work: the gateway is built over Slack's official MIT-licensed SDKs — [`@slack/socket-mode` and `@slack/web-api`](https://github.com/slackapi/node-slack-sdk) — where we contribute upstream rather than fork when possible; the memory-curation inner loop follows the pattern popularized by Letta; and the skill-lifecycle design draws on ideas explored in earlier MIT-licensed community projects in this category. The channel-agent product category was defined by Anthropic's Claude Tag; Libero exists to offer a self-hosted, model-agnostic, source-available take on it.
