Fridica
Fridica is your Slack presence. It listens in the channels you choose, as you, with your user token. It decides when to take part and replies in threads in your first-person voice. When a request needs real work, it delegates that work to Claude Code or Codex workers running on your machines: your laptop, a GPU box over SSH, and later a Slurm cluster.
| Slack / infrastructure | Fridica |
|---|---|
| Workspace | Parent agent: your representative, one per daemon |
| Channel | Social and routing namespace; never tied to a machine |
| Thread | Thread session: goal, summary, decisions, sticky machine, repo, and branch, and its workers |
| Delegated job | Worker: a Claude Code or Codex session bound to one machine and one workspace |
| Machine | An entry in the machine registry: how to reach it, its capabilities, workspaces, and policy |
Slack (Socket Mode, your user token)
│
┌─────────┴─────────┐
│ parent agent │ tool-less: triage, reply, delegate
└─────────┬─────────┘
┌─────────────────────┼─────────────────────┐
thread 1720.01 thread 1720.44 thread 1721.09 one serial actor per thread
┌────┴────┐ │
worker A worker B worker C compact WorkerResults flow up
│ │ │
ssh: snowy ssh: dart9 local agents run on the machine itself
codex app- claude -p codex app-server
server stream-json
- The thread is the unit of state. A reply in a thread reaches that thread's session, and follow-ups need not repeat "on snowy, in exocubed": the session remembers.
- Machine state and conversation state are separate. "Also try it on dart9" adds a second worker. The snowy worker keeps its own context, files, and backend session. "Fix snowy and rerun" goes only to the snowy worker.
- Context compaction by hierarchy. A worker may read a hundred files and run
dozens of commands. It returns a small structured
WorkerResult(summary, files changed, validation, artifacts, machine state, open questions, and a Slack-ready report). The parent never sees raw shell output, and Slack sees about 300 tokens. - One level of delegation. You, the parent, then workers. Workers never spawn workers.
- Agents never construct SSH commands. The parent names a machine, or capability
tags such as
cudaorrtx5090, plus a workspace. The registry inconfig.tomlsays how to reach it and what workers there may do.
One daemon serves one owner with one Slack app. Anyone in a configured channel can
talk to your Fridica. Only channels you list in delegate_channels (default: all
configured channels) can start work on your machines.
Install
Use macOS or Linux with Python 3.11 or newer. On the machine that runs the daemon,
install and sign in to the CLI the parent uses:
Claude Code (default) or
Codex CLI. On every machine that runs
workers, install and sign in to the backends you list for it there, on the login
shell's PATH.
pip install fridica # or: git clone … && pip install -e '.[dev]'
fridica init # ~/.config/fridica/config.toml, contract.md, manifest.yaml
Sandbox dependencies on Linux
Both backends run agent commands inside a bubblewrap
sandbox on Linux; macOS uses the built-in sandbox-exec and needs nothing extra.
Install these on every Linux machine that runs workers, not only the one running the daemon.
| Backend | bubblewrap |
socat |
Notes |
|---|---|---|---|
| Claude | required (system package) | required | Fridica starts Claude with sandbox.failIfUnavailable, so Claude refuses to run at all when either is missing |
| Codex | bundled, but a system bwrap on PATH is preferred |
not needed | Installing the system package lets one AppArmor profile cover both backends |
1. Install the packages.
# Debian / Ubuntu
sudo apt install bubblewrap socat
# Fedora
sudo dnf install bubblewrap socat
2. Allow bubblewrap to create user namespaces (Ubuntu 24.04 and later).
Ubuntu's default AppArmor policy blocks unprivileged user namespaces, so every
sandboxed command fails with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted even though the packages are installed. Check the setting:
sysctl kernel.apparmor_restrict_unprivileged_userns
If it prints 1, install an AppArmor profile that grants bwrap the
capability (the profile applies to bwrap only, not to the commands it runs
inside the sandbox), then reload AppArmor:
sudo tee /etc/apparmor.d/bwrap > /dev/null <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
include if exists <local/bwrap>
}
EOF
sudo systemctl reload apparmor
If it prints 0 or No such file or directory, skip this step. This follows
Claude Code's sandboxing guide;
Codex uses the same system bwrap, so the profile fixes both backends.
3. Verify.
fridica doctor # expect: PASS sandbox … (bubblewrap user namespaces)
doctor checks, on every configured machine, that the packages are present and
then runs /bin/true inside a bubblewrap user namespace, so it fails with the
exact bwrap: error when either step above is incomplete.
Configure Slack
Use one Slack app per person, with a user token for that person's account.
Do not share tokens. Other channel members need no installation to interact with
your running Fridica; members of delegate_channels can start jobs on your machines.
Separate owners must not share an app: multiple Socket Mode connections divide
events rather than broadcasting them to every connection. See
Slack's Socket Mode documentation.
1. Create the app and set permissions
Open Slack app management, choose Create New App →
From an app manifest, select your workspace, and paste
slack/manifest.yaml. The manifest sets up public channels.
Verify these settings before installation:
| Slack settings page | Setting | Required value |
|---|---|---|
| Socket Mode | Enable Socket Mode | On |
| Basic Information → App-Level Tokens | Generate token and scopes | connections:write; save the xapp- token |
| Event Subscriptions | Enable Events | On |
| Event Subscriptions → Subscribe to events on behalf of users | Public-channel event | message.channels |
| OAuth & Permissions → User Token Scopes | Public-channel messages | channels:history |
| OAuth & Permissions → User Token Scopes | Channel information and membership checks | channels:read |
| OAuth & Permissions → User Token Scopes | Send replies as your account | chat:write |
| OAuth & Permissions → User Token Scopes | Upload details files, figures, and PDFs to threads | files:write |
| OAuth & Permissions → User Token Scopes | Display names (included in the manifest; optional) | users:read |
Bot Token Scopes and bot event subscriptions are not used. You do not need a public Request URL with Socket Mode.
For private channels, also add these before installation:
| Slack settings page | Additional values |
|---|---|
| OAuth & Permissions → User Token Scopes | groups:history, groups:read |
| Event Subscriptions → Subscribe to events on behalf of users | message.groups |
Keep the public-channel settings if monitoring both types. DMs and group DMs are not supported. The authorized person must belong to every configured channel. Event subscriptions and their corresponding scopes are both necessary; see Slack's Events API and private-channel events.
Do not add unrelated scopes:
links:readandlinks:writeare for shared-link events and custom unfurls; ordinary replies containing URLs need onlychat:write. Fridica disables unfurls.- File download, reactions, channel administration, email, and bot mention
scopes are not needed. What workers may touch on your machines is decided by
machine policies in
config.toml, not by Slack scopes. metadata.message:readwas listed in older manifests, but Slack documents it as a bot/legacy-bot scope, not a user-token scope. Do not add a bot token just for this scope. Fridica still attempts to attach metadata to outgoing replies; metadata availability and acceptance are separate from ordinary message access. See Slack's metadata scope reference and link permissions.
2. Install and export tokens
Under OAuth & Permissions, select Install to Workspace and authorize as
the person Fridica will represent. An administrator may need to approve the app
and requested scopes. Copy the User OAuth Token starting with xoxp-, not a
bot token (xoxb-). Both tokens must belong to the same app and intended workspace.
For SLACK_APP_TOKEN, open Basic Information → App-Level Tokens and copy the
xapp- token already generated in step 1 with connections:write. Token generation
should be complete by this point; reuse that token rather than creating another.
For SLACK_USER_TOKEN, use the xoxp- User OAuth Token from
OAuth & Permissions after installation.
Export them in the terminal where Fridica will run:
export SLACK_APP_TOKEN='xapp-your-token'
export SLACK_USER_TOKEN='xoxp-your-token'
After changing scopes, reinstall the app, update the exported user token if Slack replaces it, and restart Fridica. Save event-subscription changes as well. Never commit tokens to Git or put them in an agent-accessible workspace.
3. Set your identity and channels
fridica configure --detect # pick channels from a numbered list
fridica configure --detect --channel-name general --channel-name my-project
fridica configure --owner-id U123ABC --workspace-id T123ABC --channel-id C123ABC # manual
Detection reads your identity with
auth.test and your joined
channels with conversations.list,
using SLACK_USER_TOKEN only. It posts nothing. configure keeps the comments in
config.toml and only fills in [owner] slack_user, [slack] workspace and
[slack] channels.
Configure machines
config.toml has a fixed set of tables, and unknown keys are errors. Defaults live
in fridica/config/schema.py. A complete example:
[owner]
slack_user = "U123ABC"
profile = "Planetary atmospheres; maintainer of snapy and kintera."
[slack]
workspace = "T123ABC"
channels = ["C0RESEARCH", "C0LAB"]
delegate_channels = ["C0RESEARCH"] # who may start jobs on your machines (default: every channel)
general_messages = true # consider joining without an @mention (a cheap triage call decides)
cooldown = 60
[parent]
backend = "claude" # always local and tool-less
model = "" # "" = the CLI's default
triage_model = "" # e.g. a cheaper model for the join-or-not decision
default_machine = "laptop"
[limits]
max_wait_replies = 3 # consecutive clarifying questions before the thread pauses
max_no_progress = 3 # acknowledgments or repeats before the thread pauses
max_delegations_per_turn = 3
max_workers_per_thread = 4
max_jobs = 4 # jobs running at once, across all machines
job_timeout = 14400
worker_idle = 1800 # keep an idle worker process this long for follow-ups
auto_resume = false # rerun jobs a restart interrupted, continuing their sessions
[policy] # defaults for every machine; machines and workspaces override
mode = "write" # read-only | write | full
network = ["github.com", "pypi.org", "files.pythonhosted.org"]
approvals = "auto" # auto (default) | on-request | untrusted | never
approval_timeout = 1800
auto_approve = ["pytest", "git status"] # exact commands or "prefix …"; never with ; | & $() etc.
auto_deny = ["rm -rf"]
[machines.laptop]
transport = "local"
backends = ["claude", "codex"]
[machines.laptop.workspaces]
notes = { path = "~/notes", policy = { mode = "read-only" } }
fridica = { path = "~/scix/repos/fridica", subfolders = false } # a single checkout: no worker1/worker2 inside it
[machines.snowy]
transport = "ssh"
host = "snowy" # an ~/.ssh/config alias; `ssh snowy` must work without a prompt
tags = ["cuda", "rtx5090"]
backends = ["codex", "claude"] # the first is the default
max_workers = 3 # live worker processes on this machine (default 4)
max_jobs = 2 # running jobs on this machine (default 2)
resources = { cpus = 32, gpus = [0], gpu_type = "RTX 5090", memory_gb = 128 } # GPUs turn on gpu_confine
[machines.snowy.workspaces]
exocubed = "~/scix/repos/exocubed"
canoe = "/home/me/canoe"
[machines.dart9]
host = "me@dart9" # transport defaults to ssh
tags = ["cuda", "gcc"]
backends = ["codex"]
[machines.dart9.workspaces]
canoe = "~/repos/canoe"
[machines.greatlakes] # registered, validated, and shown to the parent; jobs fail until Slurm lands
transport = "slurm"
host = "greatlakes"
slurm = { account = "me0", partition = "gpu", gres = "gpu:1", time = "04:00:00" }
[machines.greatlakes.workspaces]
scratch = "/scratch/me0/me"
[state]
path = "~/.local/state/fridica/state.sqlite3"
# control_socket = "…" # default: beside the state database, or a short runtime path
How work is placed. A delegation names a machine, or capability tags, plus a workspace and optionally a backend. The order of precedence:
- an explicit machine;
- tags (preferring the thread's machine, then the least busy);
- the thread's sticky machine;
default_machine.
A workspace that exists on exactly one machine selects that machine. Ambiguity is an error listing the candidates, which the parent fixes in one repair round. The parent sees machine names, tags, workspace names, and load, never filesystem paths.
Policy modes:
mode |
Codex worker | Claude worker |
|---|---|---|
read-only |
readOnly sandbox |
only Read, Glob, and Grep |
write |
workspaceWrite sandbox in the workspace |
edits accepted, Bash sandboxed with network as its domain allowlist |
full |
no sandbox | no sandbox (bypassPermissions when approvals = "never") |
Codex supports network access only as all or nothing, so any network entry gives
Codex workers full network access.
GPUs. The backends' own sandboxes hide the GPU device nodes, so a worker in
them cannot run CUDA. gpu_confine runs the backend with its own sandbox off,
inside Fridica's bubblewrap confinement, which exposes /dev. It turns on
automatically for write-mode workspaces on a machine that declares
resources.gpus:
- read-only workspaces keep the backend sandbox, because confinement can't enforce read-only;
full-mode workspaces have no sandbox hiding the GPUs to begin with;gpu_confine = falseopts a machine or workspace out;- an explicit
gpu_confine = truerequiresresources.gpusand can't be combined withread-only.
Under the confinement:
- only the worker's own workspace is writable;
- the backends' settings files are bound read-only;
- the host network is shared, because the CLI must reach its model API.
resources are declarative. They are shown to the parent and enforced as
OMP_NUM_THREADS and CUDA_VISIBLE_DEVICES.
Job slots, subfolders, and GPUs. A machine runs up to max_jobs jobs at once,
one per slot, and each worker keeps its slot for its whole life. The slots
split the machine's resources.gpus, passed to the job as CUDA_VISIBLE_DEVICES:
- 2 GPUs with
max_jobs = 2: slot 1 gets GPU 0 and slot 2 gets GPU 1; - 4 GPUs with 2 slots: 0–1 and 2–3;
- fewer GPUs than slots: GPUs are shared round robin.
Each slot also works in its own subfolder of a writable workspace, created on first use:
[machines.dungeon2.workspaces]
ai = "/data01/ai_workspace" # slot 1: …/worker1, slot 2: …/worker2
fridica = { path = "~/repos/fridica", subfolders = false } # a workspace that is itself one checkout
Two concurrent jobs then never share a directory or a GPU. Subfolders are on by
default for writable workspaces and off for read-only ones. Turn them off for a
workspace that is a single repository checkout, where worker1/ inside the repo
would make no sense; jobs there share the directory. A worker whose slot is
busy waits rather than moving to another slot, because its session (and, for
Claude, the ability to resume it) is tied to its directory. Multi-GPU jobs need
max_jobs = 1, so the single slot gets every GPU.
The daemon rereads config.toml when it changes. A rejected edit keeps the running
configuration. Machine changes apply to workers started afterwards.
Agent contract
contract.md, next to config.toml, is the rulebook, and it is reread on every call.
Only text under ## headings reaches a model:
| Section | Governs |
|---|---|
## Participation (required) |
the triage call that decides whether to join an unaddressed conversation |
## Replies (required) |
the parent's voice and reply rules |
## Delegation |
when and how the parent delegates, fans out, follows up, and composes results |
## Worker reports |
standing instructions every worker receives |
## Debriefs |
the closing debrief of a finished discussion |
any other ## section |
given to both the parent and the workers (the packaged ## Repo rules is an example) |
Missing optional sections fall back to the packaged ones.
Repository list
fridica/parent/repos.toml ships with the package and is shared by the whole team.
Change it through a pull request. Each entry has a name, a GitHub URL, and
collaborators; the first collaborator is the owner, and their word is final. The
list travels to the parent and to workers as data. Workers find checkouts by git
remote, because entries never contain local paths. [parent] repos = "…" overrides
the list for local testing.
Run
fridica doctor # config, contract, tokens, the parent CLI, and every machine
fridica start --observe-only # store messages, call nothing, post nothing
fridica start
doctor checks the following without calling a model:
- that each SSH machine is reachable without a prompt;
- that every workspace exists;
- that each backend is installed, new enough, and signed in (for Codex, the app-server
protocol must include approvals,
turn/interruptandoutputSchema); - the bubblewrap and socat sandbox, including user namespaces;
- a warning when
~/.codex/config.tomldefines MCP servers, becausecodex app-servercannot ignore the user config.
Slack authorization and channel membership are verified by start.
In Slack
#research
Alice: @you compare this branch on snowy and dart9
you: Starting on both machines; I'll post the numbers here. ← parent delegates two jobs
you: snowy (RTX 5090): 1.82 s/step. dart9: 2.34 s/step. The gap is ← one reply once both finish
the device init path; details attached.
Alice: can you fix snowy and rerun?
you: On it. ← only the snowy worker, same session
you: Fixed the device selection order; 18/18 tests and the 2-GPU run pass.
- @mentions always get a reply. Unaddressed messages go through a cheap triage
call when
general_messagesis on, limited by a per-channel cooldown. Follow-ups in a thread you are already part of are triaged too. - Clarification. A reply with status
waitingaddresses the requester, and their next message is answered without a mention.max_wait_repliesconsecutive questions pause the thread. - Results. Delegations made in one turn form a group. The parent writes one
reply when all of them finish, and says when workers disagree. A single finished
job's
reportis posted directly with no second parent call. Figures and PDFs a worker lists are uploaded after the reply. Long replies keep an executive summary in the thread and attach the rest as a Markdown file. - No turn limit. A thread can go on as long as people keep talking to you.
Runaway exchanges are stopped by the loop protections below instead: clarifying
questions (
max_wait_replies) and turns without progress (max_no_progress) pause the thread, and the dashboard orfridica threads ID resumerestarts it. - Debrief. When the parent marks a discussion finished, a debrief is posted to the channel.
- Several owners' Fridicas in one thread. Every post carries metadata:
{owner, session, turn, status, kind}, plustask_idfor older versions. A peer's finished reply or debrief is ignored unless it addresses you, and a thread pauses after repeated questions or turns without progress, so agents cannot talk to each other forever. - Your own messages never trigger your agent. The echo of Fridica's own posts is recognized and stored as history.
Approvals
approvals |
Who decides a worker's requests beyond its policy |
|---|---|
never |
nobody; the request is refused and the worker carries on without it |
on-request |
you, when the worker needs more than its sandbox allows |
untrusted |
you, for edits and most commands, even inside the sandbox |
auto (default) |
the backend's own AI reviewer: Claude's auto permission mode, or Codex's auto_review guardian |
With auto, Codex's reviewer approves or denies each request itself (its decisions
are logged). Claude's classifier needs a model that supports auto mode. On other
models Claude falls back to default mode, logs a warning, and sends its prompts to
you instead. doctor checks that each backend supports auto on machines that use it.
With on-request or untrusted, a worker's request for something outside its
policy is routed to you. For Codex these are commands, file changes,
and extra permissions from the app-server protocol. For Claude, they are tool calls
outside its allowlist, through the stream-json control protocol. The request works
the same over SSH. While it waits, the job holds its machine slot.
fridica approvals # pending requests
fridica approvals a1b2c3 once # or: session | deny
auto_approve and auto_deny prefixes decide simple commands without asking.
Anything containing shell control characters always asks. After approval_timeout
the request is denied and the worker continues without it. Interrupting a job
denies its pending request at once.
Dashboard and CLI
The daemon serves a control API on a Unix socket that only you can open (mode 0600). The CLI and the dashboard both use it and never write the database.
fridica status | threads [ID [resume|pause|close|archive|restore|clean]] | workers [ID interrupt|stop]
fridica machines | outbox [ID] # outbox ID retries a failed or ambiguous post
fridica dashboard --port 8765 # prints http://127.0.0.1:8765/#key=…
The dashboard shows approvals, stalled threads, active jobs, and failed posts. It also has thread, worker, machine, activity, and settings views. The owner can give a thread a private instruction; it follows the normal worker scopes and approvals. Settings can change the parent model and workload limits. Live refresh can be switched off; viewing the page makes no model calls.
It listens on 127.0.0.1 only, rejects cross-origin requests, and requires the printed key for every API call.
State, recovery, and guarantees
The daemon owns a single SQLite database. fridica/store/schema.py is the only
module that runs DDL, and migrations are versioned.
- Persist before acknowledging. A Slack event is stored, together with its thread and inbox row, before Socket Mode is acked. A catch-up pass re-reads the last hour at start, and the last 15 minutes every 5 minutes.
- One serial actor per thread, threads in parallel. An inbox item's effects commit in one transaction: posts, jobs, workers, session changes, and parent-call records. A crash either retries the item from scratch or leaves it fully applied.
- Every post goes through the outbox, including notices, reports, debriefs,
and uploads. Each has an idempotency key, and posts go out in order
within each thread.
- A rate limit reschedules the post.
- A rejection fails it.
- An unknown outcome (5xx, a dropped connection) marks it
ambiguous, which is never resent automatically. - Posts that depend on a failed one are marked
blocked, where you can see them.
- Restart recovery:
- Jobs that were running become
interrupted, and their thread is told.auto_resumereruns them once, continuing their sessions. - Pending approvals expire.
- Posts that may have been sent become
ambiguous.
- Jobs that were running become
- A second daemon on the same database is refused. The database is bound to one Slack identity.
Security model
- Slack tokens are removed from every agent's environment. The parent has no tools.
- Workers run under their backend's sandbox, or Fridica's bubblewrap for
gpu_confine, with per-workspace policy. - A writable local workspace may not contain Fridica itself,
config.toml, or the contract. - Links are followed only into configured channels, so nobody can make Fridica read a channel they cannot read.
- Workers' artifacts are read only from inside their workspace, with symlinks resolved, and must match their declared type (PNG, PDF, or UTF-8 Markdown).
- Message text, notes, linked messages, and worker results are passed to models as data, marked untrusted.
Development
pip install -e '.[dev]'
python -m pytest -q # no network; fake Slack, fake claude/codex/ssh/bwrap executables
node --test tests/dashboard.test.cjs
ruff check src tests
The architecture is described in docs/architecture.md.
Continuous integration (.github/workflows/ci.yml) runs pytest and the node
test on Ubuntu and macOS, and builds and checks the wheel.
Releases:
- When a pull request is merged,
cd.ymltags the next version using itsrelease:*label, then creates a GitHub release. release.ymlpublishes a tag to PyPI afterscripts/release.py verifyconfirms that the wheel bundles the Slack manifest, the contract, the repository list, the configuration template, and the dashboard.
Release files for fridica 0.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fridica-0.3.2.tar.gz | 568.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fridica-0.3.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.1 MB
Release files / fridica-0.3.2.tar.gz
| Download URL | fridica-0.3.2.tar.gz |
|---|---|
| Size | 568.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7f22be6e5429110ba179dbe008c8d678e429c6f85d9315779e3fa515590de408
|
|
BLAKE2b-256 checksum How to use checksums |
f9990ef7ad6b9fe2d5a769b89dcc44b762c408b8c9336621b992e7f059704dc3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / fridica-0.3.2-py3-none-any.whl
| Download URL | fridica-0.3.2-py3-none-any.whl |
|---|---|
| Size | 497.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
35e17f9307aa0c42877c690cca55ac585ee789a679458b2d3e90b6c791b69424
|
|
BLAKE2b-256 checksum How to use checksums |
1a55c91790f1cfd6023fb54c87d318057b2b376aaad7a39297770026f56a9864
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|