The proposal in ten decisions
- D1
One small package
Python 3.11+, installed with uv, running as one asyncio process. All state lives in
~/.attache, or whereverATTACHE_HOMEpoints. The same package runs in Docker later. - D2
Every optional capability is a skill
One folder of Agent Skills directories holds four kinds of skill: knowledge, scripts, channels and artifact templates. The core never learns about Gmail or Slack.
- D3
Skills are living documents
The agent edits a skill when you change how you work.
~/.attacheis a git repo, so every edit is a commit you can read and undo. Only your own messages can change a skill. - D4
Model backends are config rows
Four small adapters:
anthropic,openai-chat,openai-responsesandclaude-cli. Any endpoint that speaks one of those protocols is one block of TOML. Your own chats use your Claude login, and group chats use an API key. No LiteLLM and no agent framework. - D5
Channels run as sidecar processes
Each channel skill ships a
channel.pythat uv runs with its own dependencies. It talks to the core in JSON lines over stdin and stdout. Slack and Signal come first. Each sidecar shows approval requests its own way: buttons in Slack, a reaction prompt in Signal. - D6
Artifacts are local pages over real data
The agent writes HTML. The built-in server adds a small
attachebridge to the SQLite store, with live updates. People in chat apps get a PNG snapshot and a signed link. - D7
Group chats follow a fixed policy
Access, then trigger, then role. Each role has its own toolset, and a guest's turn never has private data and outbound sending at the same time.
- D8
Three surfaces, each with one job
The CLI sets up and operates. One local web page holds chat and artifacts. Chat apps cover the phone and the group.
- D9
Size is a test
CI fails if the core passes 2,860 lines of code. Blank lines, comments and docstrings don't count, so the budget never rewards dense code. New features arrive as skills, and the contribution guide says so.
- D10
One mail skill for every account
Outlook through Outlook classic's COM interface or Microsoft Graph with OAuth, your choice per account. Gmail and Titan over IMAP. All behind the same commands. It reads and files on its own, saves replies as drafts, and sends only after your yes.
What the research found
I read nanoclaw at three points in its history: the first week, the v1.2.0 tag and today's v2. I also looked at OpenClaw, the project nanoclaw was written in reaction to, and at nanobot, the best-known Python project in this space.
| nanoclaw v1 | nanoclaw v2 | OpenClaw | nanobot | Attaché | |
|---|---|---|---|---|---|
| Language | TypeScript | TypeScript | TypeScript | Python | Python |
| Core size | ~2,700 lines (Feb), 3,800 (Mar) | ~47,000 lines, not counting tests | nanoclaw's README cites “nearly half a million lines” | ~4,000 at launch, ~195,000 now (estimate) | 2,850 lines of code after model keyring support, 2,860 cap |
| Install | Fork, then /setup in Claude Code | Same, Docker required | npm, gateway daemon | uv tool install nanobot-ai | uvx attache |
| Models | Claude via the Agent SDK | Claude; Codex, OpenCode, Ollama as skills | Many, via config | Native OpenAI and Anthropic SDKs | Four adapters, any endpoint |
| Extending it | Skills that rewrite your fork | Skills copy code from registry branches | Plugins, ClawHub skills | Built-in channels, skills | Runtime skills folder, no code changes |
| Interface | Chat apps only | Chat apps only | Control UI, CLI, TUI, apps | TUI and web UI | CLI, one web page, artifacts |
| Isolation | Container per group | Container and credential vault | Host by default, sandbox optional | Host | Toolsets by role; container later |
“Accepted: Bug fixes, security fixes, simplifications, reducing code. Not accepted: Features, capabilities, compatibility, enhancements. These should be skills.” (nanoclaw, CONTRIBUTING.md)
Keep from nanoclaw
- “Skills over features” as a written rule.
- One memory file per chat, plus a global one.
- Engage modes:
mention,mention-stickyandpattern. - One owner chat with admin rights. Other chats are restricted.
- Scheduled tasks run as ordinary turns in a chat.
Change for a pip package
- A uv-installed tool has no fork to rewrite. nanoclaw's
/add-slackedits source code. Here theslackskill adds Slack at runtime by shipping its own sidecar. - Not tied to one vendor. Four adapters from day one.
- A web page for chat and artifacts. nanoclaw ruled one out (“we're not adding a web UI at this stage”, PR #212). Interactive artifacts need a page to live in.
- No container requirement, so
uvx attacheworks on a laptop with nothing else installed. - A size budget enforced by a test. nanoclaw grew about 17 times past its launch size, and nanobot about 50 times.
Architecture
One process. Channels feed a router, the router builds a turn, a backend runs it, and tools act on the world. All state lives in one folder and one SQLite file.
claude-cli backend, Claude Code runs the loop itself and reaches the same tools through an MCP endpoint on the local web server.One message, start to finish
- Jo writes “@Attaché can you send the packing list as a PDF?” in the family Signal group. The Signal sidecar, a thin wrapper around signal-cli's JSON-RPC mode, turns it into one JSON line on its stdout.
- The router checks that the chat is allowed, sees the mention and looks Jo up as a member. Members can use this chat's files, send to this chat only, use shared collections and make artifacts.
- It builds the turn: the persona, this chat's memory file, the full text of the skills members may use, the last 30 messages with other people's words fenced and labelled as data, and Jo's identity taken from the config. Your own
USER.mdstays out of anyone else's turn. - The backend runs the loop. The model follows the pdf skill, runs its script through the
scripttool (which usesuv run), and callssendwith the file. - The core writes a
sendline to the sidecar's stdin, and the bot posts the PDF to the group with a caption.
Skills, the part that grows
A skill is a folder with a SKILL.md, following the Agent Skills standard that Claude Code, Codex and others read. Only each skill's name and description go into the prompt, about a hundred tokens each. The agent opens the full file when a task needs it, so forty skills cost less than one long system prompt.
attache.toml endpoints, people, chats
attache.db messages, docs, tasks
memory/
USER.md who you are, VIPs, tone
chats/family.md one file per chat
skills/
mailbox/ knowledge: yours
SKILL.md
mail/ script: Outlook, IMAP
SKILL.md
accounts.toml
mail.py PEP 723: pywin32, imapclient
todos/ lists, fields, habits
docx/ script
SKILL.md
scripts/make.py PEP 723: python-docx
slack/ channel
SKILL.md
channel.py
signal/ channel, wraps signal-cli
artifact-kit/ templates
SKILL.md
tokens.css
templates/board.html
artifacts/
todo-board.html
---
name: mailbox
description: How I file and triage my email.
Use when sorting, archiving, drafting replies
or summarising the inbox.
metadata:
attache-owner-only: "true"
---
# Mailbox
## Accounts
- outlook: personal
- gmail: shopping, newsletters
- titan: business
## Folders
- Finance/Receipts: receipts, invoices
- Family: Sam, Jo, school
- Reading: newsletters worth keeping
## Each morning at 07:30
1. List unread mail in every account.
2. File anything matching a rule below.
3. Flag mail from VIPs in USER.md.
4. Draft replies I'll need. Never send.
5. Post a five-line summary to my Slack DM.
## Learned rules
<!-- appended when you correct me -->
- Substack goes to Reading. (12 Oct)
| Kind | What the folder holds | Examples | Who edits it |
|---|---|---|---|
| Knowledge | Only SKILL.md: rules, preferences, procedures | mailbox, todos, travel, tone of voice | You and the agent |
| Script | scripts/*.py with PEP 723 headers. uv run installs each script's dependencies on first use, outside the core | mail (Outlook through COM, Gmail and Titan over IMAP), docx, xlsx, pptx, pdf | Bundled; the agent can write new ones |
| Channel | channel.py, started as a sidecar when attache run starts | slack, signal, discord, matrix | Bundled; the agent can draft one |
| Template | templates/*.html and shared design tokens for artifacts | artifact-kit: board, checklist, table, itinerary | You and the agent |
How a skill changes itself
$ attache skills log mailbox
a41c9e2 mailbox: Amazon receipts → Finance/Amazon
you, Slack DM, 19 Oct 09:14
## Folders
-- Finance/Receipts: receipts, invoices
+- Finance/Receipts: receipts, invoices (not Amazon)
+- Finance/Amazon: Amazon receipts and orders
- Only turns from the owner can write under
skills/. When a member asks for a change, such as “always send the shopping list as a checklist”, the assistant drafts the edit and sends it to your DM for a yes or no. - After any turn that touched
skills/ormemory/, the core commits with the request as the message.attache skills undo mailboxreverts the last change. attache initcopies the bundled skills into your folder. Upgrades to them arrive as a git merge, so your edits stay.- A bundled
skill-writerskill lets the agent create new skills from a conversation, as nanobot'sskill-creatordoes. - New abilities arrive the same way. Asked to “add Signal”, the agent drafts
skills/signal/with achannel.pythat talks to signal-cli. You review the commit, and it starts on the next restart.
Email: Outlook, Gmail and Titan
One mail skill gives the agent the same commands for all three accounts. Behind them sit three backends. Outlook uses either Outlook classic on this PC through its COM interface, or Microsoft Graph with an OAuth sign-in; you pick per account. Gmail and Titan use IMAP with SMTP. Your filing rules live in the mailbox knowledge skill, so they don't change with the backend.
| Account | Backend | Sign-in | Calendar | Off this PC |
|---|---|---|---|---|
| Outlook | Outlook classic through COM, or Microsoft Graph | COM: Outlook's own. Graph: a one-time browser sign-in | Yes | Graph: works as is. COM: this PC only |
| Gmail | IMAP and SMTP | An app password, which needs 2-Step Verification | Not in v1 | Works as is |
| Titan | IMAP and SMTP | Your Titan password, or an Application Password if 2FA is on. “Enable Titan on Other Apps” must be switched on in webmail | Not in v1 | Works as is |
Outlook.com and Microsoft 365 refuse password sign-in over IMAP, so the Outlook account goes through Outlook classic or through Graph. The Gmail and Titan passwords are kept in Windows Credential Manager through keyring, never in a config file or in git.
Outlook classic is installed (Microsoft 365 Family or Personal, 64-bit, version 16.0.20430) with one mail profile. The new Outlook app is installed too, but the setting that switches you to it isn't set. A test script that declared pywin32 ran under uv run and loaded Outlook's COM support with no extra setup: 3.6 s the first time, 0.7 s after that. Outlook was closed, and nothing opened it or read any mail. The Gmail, Titan and Outlook.com servers were asked, without signing in, which sign-in methods they accept.
Outlook: COM or Graph
| Outlook classic (COM) | Microsoft Graph (OAuth) | |
|---|---|---|
| Setup | None. Uses the Outlook already signed in on this PC | Once: an app registration in Microsoft Entra, then a browser sign-in. A personal Microsoft account needs a free Azure account to get a tenant (phone number and card, with a temporary $1 hold) |
| Outlook running | Started if needed | Not needed |
| New Outlook, other machines, cloud | No | Yes |
| Calendar and tasks | Outlook calendar and tasks | Calendar and Microsoft To Do |
| Item IDs | Change when an item moves | Immutable IDs survive moves |
| Staying signed in | Outlook's own | 90 days from the last use, renewed each time. Ends when you change your Microsoft password |
| Consent screen | Says “unverified”: apps registered with a personal account can't be publisher-verified | |
| Work accounts | Whatever Outlook can open | An admin may need to approve the app |
| Where secrets live | Nowhere new | A token cache encrypted for your Windows user (DPAPI) |
| Skill dependencies | pywin32 | msal, msal-extensions |
Start with COM, since there is nothing to register. Switch the Outlook account to Graph when you want it to work with Outlook closed, on another machine or in the cloud. The switch is one line in accounts.toml, and the mailbox skill doesn't change. The app ID is a public value, not a secret, so the project could register one app and ship its ID. Until it does, you register your own.
[outlook]
backend = "com" # or "graph", which also needs:
# client_id = "<your app ID>"
[gmail]
backend = "imap"
imap = "imap.gmail.com:993"
smtp = "smtp.gmail.com:465"
user = "you@gmail.com"
[titan]
backend = "imap"
imap = "imap.titan.email:993"
smtp = "smtp.titan.email:465"
user = "you@yourdomain.com"
$ uv run mail.py inbox --account all --unread
{"ok": true, "items": [
{"id": "ol:AAAAAJ3pQ…", "account": "outlook",
"subject": "Boiler service quote",
"from": {"name": "Dan Price",
"smtp": "dan@example.com"},
"received_utc": "2026-10-18T16:42:00Z",
"preview": "Hi, attached is the quote…",
"untrusted": true},
{"id": "gm:1801234567890123456",
"account": "gmail",
"subject": "Your order has shipped", …}]}
| Commands | What they do | Runs |
|---|---|---|
doctor folders login | Check each account is reachable and say why not, list folders. login is the one-time Graph sign-in | Any time |
inbox search read | List, find and open mail. Bodies come back as capped plain text, marked untrusted | On its own |
agenda tasks | The Outlook calendar for the coming days with recurring meetings expanded, plus Outlook tasks (COM) or Microsoft To Do (Graph) | On its own |
move flag mark-read | File and organise. On Gmail a folder is a label, so move adds the label and takes the message out of the inbox. Moving more than 20 items at once needs your yes | On its own, logged |
draft-reply draft-new | Save a reply or a new message in that account's Drafts | On its own, logged |
send-draft respond create-event | Send a draft, answer a meeting invite, create an event with attendees | After your yes |
| Not built | Rules, automatic forwarding, settings changes, permanent deletes | Never |
How sending works
- The agent saves the reply in that account's Drafts. The mail skill writes a preview of exactly what would go out (from, to, cc, bcc, subject, attachments and body), and the core shows you that preview, not the agent's words, with Send and Not now.
- Your Send leaves an approval that only that account, draft and preview can use, once, within 15 minutes. If anything changed in between, the send is refused. A send that may already have gone out is never retried. A turn that can run shell commands could still forge an approval; the docs say so.
- Outlook sends it itself, through COM or Graph. Gmail and Titan send through SMTP, with a fixed Message-ID so a retry can't send twice. Titan doesn't file sent mail on its own, so the skill copies it to Sent.
- Every action, read or write, is appended to
~/.attache/logs/mail.jsonl.
An inbox holds private data, untrusted content and a way to send, all in one place. So the assistant never follows instructions found inside an email, never adds recipients taken from email text, and runs this skill only on your own turns.
Limits to plan for
- Only classic Outlook has COM; the new Outlook app has none. If you switch,
mail doctorsays so; set the account to Graph and it carries on. Microsoft supports existing classic installs until at least 2029. - Attaché has to run in your own logged-in session without elevated rights. Windows refuses the connection when one side is elevated and the other isn't, and Microsoft doesn't support Office automation from background services.
attache service installsets up the scheduled task this way. - Outlook shows security prompts when antivirus isn't reported healthy or a policy forces them. On a home PC with Defender up to date it is normally silent.
- Outlook updates occasionally break COM. An April 2026 Current Channel build did until Microsoft fixed it in May, which is one reason
doctorexists. - A Graph sign-in lapses after 90 days without use or when you change your Microsoft password, and some work tenants block sign-in to unverified apps.
doctornames the cause and asks you to runloginagain. - Gmail revokes app passwords whenever you change your Google password.
doctorspots the failed sign-in and asks you for a new one. - Titan caps mail to outside addresses by plan, from 50 an hour on Starter. Approved sends are few, but the skill still counts recipients and limits each run.
Gmail, Titan and Outlook through Graph work wherever Attaché runs. Only the COM route ties the Outlook account to this PC. EWS isn't an option: Microsoft starts blocking it in Exchange Online on 1 October 2026 and removes it on 1 April 2027.
Budget: built at 1,349 lines in four files (shared commands and IMAP about 655, COM and Graph about 695), against a cap of 1,350, raised from the 1,200 first estimated; headers and free/busy status for the email channel and the diary later took it to 1,381, under a cap of 1,390. It is the biggest skill, and it is still outside the core. Early open-source Outlook MCP servers such as outlook-classic-mcp and Robvred/outlook-mcp are useful for patterns: one Outlook thread, draft-only defaults and an audit log.
Model backends
Two kinds of backend sit behind one interface. API backends return a model response, and Attaché runs the tool loop. CLI backends run their own loop and call Attaché's tools through MCP.
| Adapter | Speaks | Covers | Tool loop | Lines |
|---|---|---|---|---|
anthropic | Messages API | Claude API, and any Anthropic-compatible base_url: OpenRouter, Ollama, LM Studio, llama.cpp, vLLM, a LiteLLM proxy | Attaché | ~70 |
openai-chat | Chat Completions | Ollama, vLLM, LM Studio, llama.cpp, OpenRouter, Groq, Azure v1 | Attaché | ~90 |
openai-responses | Responses API | OpenAI reasoning models with tools | Attaché | ~60 |
claude-cli | claude -p, stream-json | Claude Code on your login or an API key, with its own tools, sandbox and skills | Claude Code | ~100 |
codex-cli later | codex exec --json | Codex on a ChatGPT login | Codex | ~80 |
[routing]
own_chats = "claude" # your Claude login
group_chats = "claude-api" # API key
[endpoints.claude]
api = "claude-cli" # your Claude Code login
model = "claude-sonnet-5-5"
[endpoints.claude-api]
api = "anthropic"
model = "claude-sonnet-5-5"
key_env = "ANTHROPIC_API_KEY"
[endpoints.openai]
api = "openai-responses"
model = "gpt-5.4"
key_env = "OPENAI_API_KEY"
[endpoints.local]
api = "openai-chat"
base_url = "http://localhost:11434/v1"
model = "qwen3:32b"
class Backend(Protocol):
async def run(self, turn: Turn) -> Reply: ...
async def tool_loop(complete, turn, steps=20):
msgs = turn.history + [user(turn.text)]
for _ in range(steps):
out = await complete(turn.system, msgs,
turn.tools)
msgs.append(out.message)
if not out.calls:
return Reply(out.text)
msgs.append(await run_tools(out.calls, turn))
return Reply("I stopped after 20 steps.")
Each API adapter only implements complete(): one request, translated to and from a shared message format. run_tools checks every call against the sender's role first.
- Official SDKs only.
anthropicandopenaitogether resolve to 17 packages. LiteLLM resolves to 59, shipped 241 releases in 2026, and had two compromised PyPI releases (1.82.7 and 1.82.8) on 24 March 2026. nanobot replaced it with the two SDKs that day. - Why two OpenAI adapters. From GPT-5.4 on, Chat Completions only allows tool calls when
reasoning_effortisnone, so OpenAI's own reasoning models need Responses. Local and hosted compatible servers mostly speak Chat Completions. - How
claude-cliworks. It runsclaude -p --output-format stream-json --verbose --resume <session>in a folder outside your home, keeps one Claude session per chat, and points--mcp-configat an MCP endpoint on the local web server, which serves only Attaché's own tools (Claude Code brings its own file and shell tools). When a session starts fresh it gets the earlier conversation as a transcript. It only answers your own chats, never a group. Theclaude-agent-sdkpackage does the same thing in-process, but it bundles the CLI (104 to 117 MB per platform wheel), so it stays an optional extra. - Subscriptions. Anthropic's terms allow a Pro or Max login for your own individual use. Anything that serves other people should use an API key. Your choice follows that:
claude-clion your login for your own chats,claude-apiwith a key for group chats. A chat can still name its own endpoint. IfANTHROPIC_API_KEYis set,claude -puses it instead of your login.
Artifacts and files on demand
An artifact is an HTML file the agent writes into artifacts/. The local server serves it with a small bridge script, so a to-do board edits the real to-do collection. An open page updates when the agent or someone else changes the data. Try the example below; the log on the right shows the calls the page would make.
const todos = await attache.db.list("todos",
{list: "Home"})
await attache.db.put("todos", id,
{...t, status: "done"})
attache.on("todos", render) // live, over SSE
// in the Attaché window: a turn with db on this page's data
const answer = await attache.ask("Suggest three more items")
The names follow Claude.ai's artifact storage API, which models already write against. Each page gets a token that reaches only the collections named in its attache-collections meta tag.
How it reaches people
- The agent calls
artifact(name="trip-checklist", html=…); the page declares<meta name="attache-collections" content="trip">. - In the web app the page opens in a sandboxed frame. It can't reach the internet, other pages or any data it didn't declare.
- In a chat app the reply is a PNG snapshot plus, for a group's page, a share link that lasts 7 days. Its token is random and only its hash is stored. It reaches only that group's shared data, and stops working when the page changes. Links are not single-use, because link-preview crawlers open them first.
- On a laptop, links go out through Tailscale Funnel when you turn sharing on. Funnel points at a second loopback port that serves share links and nothing else, with caps on what a link can write. In the cloud they use the public URL.
| Someone asks for | Made by | Lives in |
|---|---|---|
| An answer or a list | The model, as a chat message | Core |
| Markdown or CSV | Standard library | Core |
| An interactive page | artifact tool and bridge | Core |
| A PNG or PDF of a page | Chrome or Edge already on the machine, headless. Saves the 150 to 200 MB Playwright browser download | render skill |
| A Word document | python-docx through PEP 723 | docx skill |
| A spreadsheet | openpyxl | xlsx skill |
| Slides | python-pptx | pptx skill |
Generated pages share the design tokens in artifact-kit, so they look like one product. Ask for a different look and the agent edits the kit.
Group chats and people
In a group anyone can talk to Attaché, so the router settles three questions in a fixed order before a model sees anything.
- 1 · ACCESS
Is this chat allowed?
Chats and people are listed in
attache.toml, keyed by platform IDs, never display names. Unknown chats are ignored. - 2 · TRIGGER
Was Attaché addressed?
A mention, a reply to the bot, or the chat's pattern. Other messages are stored as context and never answered.
- 3 · ROLE
Who is asking?
Owner, member or guest. The role decides which tools the turn gets.
Safeguards
- A guest's turn never gets private data and outbound sending together. That combination, plus untrusted input, is what Simon Willison calls the “lethal trifecta”.
- Unmentioned messages enter the prompt fenced and labelled as transcript, never as instructions. Forged headers in message text are stripped.
- Each sender and each chat gets an hourly rate limit, and non-owner turns a daily cap, set under
[limits]. A chat that hits one hears so once. - The bot ignores its own messages and other bots.
- Sending to a different chat needs your confirmation.
- In Slack the bot only sees channels it has been invited to. In Signal it only sees groups it has been added to, and it knows people by their Signal account ID, because phone numbers are hidden by default.
| Tool access | Owner (you) | Member | Guest |
|---|---|---|---|
| Answers in chat | Yes | Yes | Only if the chat is public |
| Files and artifacts for this chat | Yes | Yes | Read-only links |
| To-dos and reminders | All lists | Tick off and add items on lists shared with the chat; own reminders | No |
| Email, calendar, other chats' memory | Yes | No | No |
| Send to other chats | Yes, after confirming | No | No |
| Shell commands | Yes | No | No |
| Edit skills | Yes | Suggest, you approve in your DM | No |
Shared lists
Members of a chat can tick off items on any list shared with that chat. Both ways of ticking update the same record, so every open copy of the list changes at once.
- In the chat: “@Attaché tick off passports”. The tick is credited to the sender, because the router knows who they are.
- On the list's page, from the link the bot posted. That link can change only that list, expires, and can be revoked. Ticks made there are logged as “via the Family link”, because a shared link can't tell people apart.
- Members can add items too. Only you can delete a list, change who it is shared with or revoke its link. If you'd rather members only tick, that is one setting.
Slack and Signal
Both run as sidecars with the same protocol, so the core treats them alike. They suit different people: Slack for you and colleagues, Signal for family and friends.
| Slack | Signal | |
|---|---|---|
| Best for | Your own DM and work channels | Family and friends' groups |
| Setup on this PC | Create the app from a manifest and paste two tokens. Pure Python (slack-bolt) | Java 25 (not installed today), signal-cli, and a phone number for the bot, verified with a captcha and an SMS or voice code |
| Public URL | Not needed (Socket Mode) | Not needed |
| Standing | Official API. A single-workspace internal app fits Slack's API terms | No official bot API. signal-cli is unofficial but long tolerated. Signal's terms forbid bulk and automated messaging |
| Who can join | Members of your workspace. The free plan allows no guests | Anyone on Signal |
| Approvals | Buttons | A 👍 or 👎 reaction, or a reply |
| Privacy | Slack stores messages. Not end-to-end encrypted | End-to-end encrypted |
| Upkeep | Low | signal-cli has to stay under three months old or it can stop working |
# attache → channel
{"type": "ask", "chat_id": "D07…", "ask_id": "a19",
"text": "Jo wants shopping lists as checklists. Update the skill?",
"options": ["Approve", "Decline"]}
# channel → attache
{"type": "answer", "ask_id": "a19",
"sender_id": "U04…", "choice": "Approve"}
The router accepts an answer only from the person the question was for. Slack draws the options as buttons. Signal sends the question as text and maps 👍 and 👎 reactions to the two options.
CLI or web? Both, kept thin
You weren't sure whether people should use a CLI or a web interface. My recommendation is three surfaces, each with one job, and none of them heavy.
| Surface | Job | Built with |
|---|---|---|
| CLI | Set up and operate: init, run, chat, skills, service, doctor | argparse |
| Local web page | Chat with history, open and pin artifacts, the Home view | One HTML file and Starlette at localhost:8765 |
| Chat apps | Daily use from your phone, and group chats | Channel skills |
$ uvx attache init
? Your name › Alex
? Model › claude-cli (your Claude Code login)
? Chat apps › slack, signal
Slack: create the app from attache-slack.yml
Bot token (xoxb-…) › ••••••••
App token (xapp-…) › ••••••••
Signal needs Java 25. Install with winget? › yes
Number for the bot › +44 7700 900123
Captcha from signalcaptchas.org › ••••••••
Code from the SMS › ••••••
✓ Wrote ~/.attache/attache.toml
✓ Copied 11 starter skills
✓ Started a git repo in ~/.attache
$ attache run
Open http://127.0.0.1:8765/#t=…
chats terminal, slack (3), signal (2)
tasks 3 scheduled, next 07:30
The Home view is itself an artifact built from artifact-kit: today's to-dos, reminders and pinned pages. Ask for a different layout and the agent edits it. The web UI code stays a chat box and an artifact frame.
attache service install keeps it running after login, using launchd on macOS, a systemd user unit on Linux and Task Scheduler on Windows. The service needs the uv tool install copy, because the uvx cache can be cleared. On Windows the task runs only while you're logged on and without elevated rights, which Outlook automation needs.
Not recommended: a Textual TUI, a React app, NiceGUI, Gradio or Streamlit. Each adds more code or dependencies than the whole web page.
Code budget
Phase 1 came in at 1,576 lines of code in src/attache, against a cap of 1,600 that CI enforces. Phase 2 added the sidecar runner, approvals, rate limits and the hardening its reviews asked for. The simplicity review cut 54 lines first, and the owner then raised the cap to 1,900, with a target of about 1,850. For phase 3 the owner raised it to 2,050, then to 2,100 for the review fixes: sharing, attache.ask from pages, persisted requests and their hardening. The terminal's progress work then raised it to 2,180, and email requests to 2,240, for script identity and background runs. The owner workspace adds validated configuration editing, safe source preservation and recovery, raising the cap to 2,700. Starting a chat afresh (/new) and long turns, which wind up on a loop and carry on in a fresh context, raised it to 2,710. The owner's model keyring plan adds 148 core code lines and raises the cap to 2,860; the core is at 2,850. Login, rotation, status and logout live in the credentials skill, at 180 lines. The optional Hailer adapter remains a skill. Mail, files, the triage and the service are all skills. Blank lines, comments and docstrings don't count, so the budget never pushes code to be denser than it should be. Bundled skills, static files and tests don't count either, but each bundled skill has its own cap: 300 lines by default, 1,390 for mail, which has three backends, and 310 for tui, which clears its screen on /new.
| Module | Job | Code lines | Share |
|---|---|---|---|
| cli.py | init, chat, run, doctor, skills, skill | 135 | |
| app.py | Wiring, the two servers, clean shutdown | 97 | |
| config.py | attache.toml and its checks, init_home, restrict | 184 | |
| credentials.py | Explicit environment or destination-bound OS keyring lookup | 111 | |
| settings.py | Owner settings projection, validation, atomic saves and recovery | 345 | |
| store.py | SQLite: messages, docs, tasks; the db rules | 160 | |
| router.py | Access, roles, fencing, prompt, limits, turns, page turns | 233 | |
| agent.py | Shared tool loop | 115 | |
| types.py | Shared types | 93 | |
| backends/anthropic.py | Messages API | 56 | |
| backends/openai.py | Chat Completions and Responses | 82 | |
| backends/claude_cli.py | claude -p, sessions, transcript | 134 | |
| backends/__init__.py | Picks a backend | 25 | |
| tools.py | Tools by role, path confinement, ask, request, mail approvals, background scripts | 314 | |
| skills.py | Discovery, index, git history | 94 | |
| channels.py | Channel manager, questions and answers, terminal, sidecars | 280 | |
| scheduler.py | Due-task loop, DST-safe cron | 44 | |
| web.py | Chat page, artifacts, bridge, SSE, MCP, attache.ask, share links | 345 | |
| Total | plus 3 lines in __init__ and __main__ | 2,850 |
[project]
name = "attache"
requires-python = ">=3.11"
dependencies = [
"anthropic", "openai",
"starlette", "uvicorn",
"pyyaml", "cronsim",
"keyring>=25,<26",
]
[project.optional-dependencies]
claude-sdk = ["claude-agent-sdk"]
[project.scripts]
attache = "attache.cli:main"
CORE_LIMIT = 2860 # blanks, comments, docstrings free
def test_core_stays_small():
lines = sum(code_lines(p) for p in core_files())
assert lines <= CORE_LIMIT, (
f"core is {lines} lines of code; "
"move the feature into a skill")
pyyaml and cronsim have no dependencies of their own. Starlette needs only anyio. keyring supplies OS credential access; the credentials skill declares tomlkit for preserving configuration comments. Other integrations, such as slack-bolt, pywin32, IMAPClient or python-docx, are declared by the skill script that needs them and never touch the core install.
Roadmap
- PHASE 1Local core
Package skeleton and budget test, config, store, terminal and web chat, the four adapters, tools, the skills folder with git, the scheduler, the
todosskill and the Home artifact.Done when
uvx attachegives a working assistant on localhost with to-dos and reminders.Built: pull request #28, version 0.0.1.
- PHASE 2Messaging
The sidecar protocol with approval requests, the
slackandsignalskills, router policy and roles, file sending, therenderskill for PNG and PDF.Done whenyou approve actions from a Slack DM, and a family Signal group can ask for answers, files and a shared checklist.
Built: pull request #34. Tested offline with a fake channel; the run against real Slack and Signal accounts is yours.
- PHASE 3Email and files
The
mailskill with its three backends: Outlook classic through COM or Microsoft Graph with OAuth, and IMAP for Gmail and Titan, with approved sends. Themailboxknowledge skill and a 07:30 triage task. Then docx, xlsx and pptx skills, sharing through Tailscale Funnel,attache.askfrom artifacts, andattache service install.Done wheneach morning the assistant files new mail in all three accounts, drafts the replies you need, sends the ones you approve in Slack, and starts at login.
Built: pull request #35. Tested offline with fakes; the run against your real mail accounts, Tailscale and Task Scheduler is yours.
- PHASE 4Cloud
A Dockerfile with
ATTACHE_HOME=/data, an owner login for the web page, a Fly.io guide (one machine, a volume, auto-stop off so the scheduler keeps running), an MCP client for the API backends, thecodex-cliadapter.Done whenthe same package runs on a small cloud machine, with your folder synced by git.
Risks and decisions
| Risk | Mitigation |
|---|---|
| The core grows | The budget test D9, features as skills D2, and a CONTRIBUTING rule copied from nanoclaw. |
| A message or email tries to steer the assistant | Incoming content is marked untrusted and never treated as instructions. Sending email, forwarding and messaging other chats need your yes D7 D10. |
| Microsoft moves you to new Outlook | Classic installs are supported until at least 2029. mail doctor detects the switch, and one line in accounts.toml moves the account to Graph. |
| A Graph sign-in expires or is blocked | doctor separates expiry, consent and policy problems and asks you to sign in again. On this PC, COM stays available as a fallback. |
| Mail passwords leak | Stored in Windows Credential Manager through keyring, never in attache.toml or git. App passwords can be revoked on their own without touching your main password. |
| signal-cli stops working after a Signal change | attache doctor checks its version, and the signal skill updates it. Releases come about monthly, and versions older than three months may fail. |
| Signal flags the bot's number | A dedicated number, low volume, and replies only to people who write to it, plus your own reminders. |
| Slack free plan limits | Attaché keeps its own message history in SQLite, so 90 days of visible Slack history doesn't limit its memory. |
| Prompt injection in groups | Toolsets by role, the trifecta rule, fenced context D7. |
| Shell access on the host | run and the file tools are owner-only and confined to ~/.attache, with .secret, the database and git blocked. claude-cli uses Claude Code's own permissions on your login, runs outside your home, and only answers your own chats. Docker for run comes later as an option. |
| Subscription terms | Group chats default to an API-key endpoint. |
| Weak tool calling on local models | Tolerant argument parsing, a list of tested models, and a model choice per chat. |
| CLI output formats change | Parse only assistant text and the final result, and test against recorded fixtures. Pin versions. |
| Windows differences | Copies instead of symlinks, Task Scheduler for the service, and CI on Windows from the start. |
Decided on 19 October
- Name:
attache. - Chat apps: Slack and Signal, both in phase 2, with Signal on your separate number. Telegram and WhatsApp are out of scope.
- Models: your Claude login (
claude-cli) for your own chats, an API key (claude-api) for group chats. - Email: Outlook through COM or Microsoft Graph with OAuth, chosen per account; Gmail and Titan over IMAP; all through the
mailskill. - Shared lists: members of a chat can tick off items on lists shared with it.
Deferred
- Cloud target. Nothing before phase 4 depends on it. Keeping everything in one folder under git, with Docker as the packaging, leaves Fly.io, Railway, a VPS and a home server all open.
To set up when we get there
- Phase 2, Slack: a workspace, and an app created from the manifest Attaché provides.
- Phase 2, Signal: Java 25 on this PC, then registering your separate number with a captcha and an SMS or voice code. Registering signs out any Signal app already using that number, so keep it for Attaché alone.
- Phase 3, Gmail: 2-Step Verification on, then an app password.
- Phase 3, Outlook through Graph (optional): a free Azure account for an Entra tenant, an app registration (desktop app, redirect
http://localhost, delegated mail, calendar and tasks permissions), thenmail.py login outlook. - Phase 3, Titan: “Enable Titan on Other Apps” in webmail, and an Application Password if 2FA is on.
Phase 1 is built and in review as a pull request in the private repo OpenAfterHours/attache. It started from a repo skeleton: the budget test, the store, the router, the anthropic and claude-cli adapters, and the terminal channel. A read-only mail doctor run against Outlook classic early on would prove COM access on this PC well before phase 3.
Sources
Checked on 19 October 2026. Line counts for nanoclaw come from its repository at three points in time. The nanobot line count is an estimate from its file tree.
- nanoclaw repository: README, CONTRIBUTING, docs
- nanoclaw PR #212: no web UI
- nanoclaw entity model: engage modes, roles
- OpenClaw repository
- OpenClaw group policy
- OpenClaw canvas
- nanobot repository
- nanobot commit replacing LiteLLM
- Claude Code headless mode
- Claude Code CLI reference
- Claude Agent SDK for Python
- Claude Code legal and compliance: subscription use
- Agent Skills specification
- OpenAI: migrate to Responses
- OpenAI deprecations
- Codex non-interactive mode
- Datadog: LiteLLM PyPI compromise
- OpenRouter Anthropic compatibility
- signal-cli
- signal-cli JSON-RPC
- Signal terms
- slack-bolt on PyPI
- Slack app manifests
- Slack rate limits for non-Marketplace apps
- Slack API terms
- Slack free plan limits
- Classic Outlook availability
- pywin32 on PyPI
- Outlook object model security behaviour
- Microsoft: unattended Office automation
- EWS retirement in Exchange Online (MC1227454)
- Outlook.com ends password sign-in for IMAP
- Gmail IMAP and SMTP
- Gmail IMAP extensions
- Google app passwords
- Gmail sending limits
- Titan IMAP and SMTP settings
- Titan application passwords
- Titan sending limits
- IMAPClient on PyPI
- Microsoft Entra: create a tenant
- Microsoft Entra: register an app
- Publisher verification
- Refresh token lifetimes
- Microsoft Graph permissions
- Microsoft Graph immutable IDs
- Microsoft Graph throttling limits
- msal on PyPI
- msal-extensions
- keyring on PyPI
- outlook-classic-mcp
- Robvred/outlook-mcp
- Slack Socket Mode
- Simon Willison: the lethal trifecta
- uv: running scripts, PEP 723
- uv: tools
- Tailscale Funnel
- Cloudflare quick tunnels
- Fly.io volumes