Attaché
Design proposal · v0.9 · 21 October 2026
A Python take on nanoclaw

A personal assistant you install with one command

Attaché runs on your own machine. You talk to it in a terminal, on a local web page and in your chat apps, including group chats. It keeps its know-how in a folder of skills, and it rewrites those skills as your habits change. The core stays small enough to read in an afternoon. Everything else is a skill.

$ uvx attache                 # try it without installing
$ uv tool install attache     # keep it
$ attache init && attache run
  • Python 3.11+
  • under 2,860 lines of code
  • 7 direct dependencies
  • Claude API, Claude Code, OpenAI, any compatible endpoint
  • Slack and Signal first, more channels as skills
  • Outlook, Gmail and Titan mail

The name is attache, after the assistant on an ambassador's staff and the case an executive carries. It was unclaimed on PyPI on 19 October 2026, so register it before the first release.

Changes in v0.9
  • People you list can email you with @attache. A new email channel skill watches one inbox, admits a message only when the topmost Authentication-Results shows DMARC passing for the sender's domain, and answers in the thread, to the sender alone. An email address can never be the owner or the approvals chat.
  • The new diary skill offers free slots only, from Outlook (through the mail skill) or a Google secret iCal address, ranked by the owner's prefer and avoid entries. Titles, attendees, locations and notes never leave the script.
  • The new jobs skill runs commands the owner defined, for the people each job lists, as background scripts: the requester hears only that it finished or failed, and the owner gets the failure notice. Every script run now gets ATTACHE_SENDER, ATTACHE_ROLE and ATTACHE_CHAT from the core.
  • The core cap is now 2,860 lines of code (the core is at 2,850, with /new, long turns and model keyring lookup), and the mail skill's 1,390 (D9, §10). Credential management is a bundled skill.
  • v0.8: phase 3 built (pull request #35): mail, Office files, share links and starting at login. v0.7: phase 2 built (pull request #34). v0.6: phase 1 built (pull request #28, version 0.0.1).
§1

The proposal in ten decisions

  1. D1

    One small package

    Python 3.11+, installed with uv, running as one asyncio process. All state lives in ~/.attache, or wherever ATTACHE_HOME points. The same package runs in Docker later.

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

  3. D3

    Skills are living documents

    The agent edits a skill when you change how you work. ~/.attache is a git repo, so every edit is a commit you can read and undo. Only your own messages can change a skill.

  4. D4

    Model backends are config rows

    Four small adapters: anthropic, openai-chat, openai-responses and claude-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.

  5. D5

    Channels run as sidecar processes

    Each channel skill ships a channel.py that 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.

  6. D6

    Artifacts are local pages over real data

    The agent writes HTML. The built-in server adds a small attache bridge to the SQLite store, with live updates. People in chat apps get a PNG snapshot and a signed link.

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

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

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

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

§2

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 v1nanoclaw v2OpenClawnanobotAttaché
LanguageTypeScriptTypeScriptTypeScriptPythonPython
Core size~2,700 lines (Feb), 3,800 (Mar)~47,000 lines, not counting testsnanoclaw'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
InstallFork, then /setup in Claude CodeSame, Docker requirednpm, gateway daemonuv tool install nanobot-aiuvx attache
ModelsClaude via the Agent SDKClaude; Codex, OpenCode, Ollama as skillsMany, via configNative OpenAI and Anthropic SDKsFour adapters, any endpoint
Extending itSkills that rewrite your forkSkills copy code from registry branchesPlugins, ClawHub skillsBuilt-in channels, skillsRuntime skills folder, no code changes
InterfaceChat apps onlyChat apps onlyControl UI, CLI, TUI, appsTUI and web UICLI, one web page, artifacts
IsolationContainer per groupContainer and credential vaultHost by default, sandbox optionalHostToolsets 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-sticky and pattern.
  • 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-slack edits source code. Here the slack skill 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 attache works 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.
§3

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.

send: replies, files and links go back out through the same channel CHANNELS TerminalCORE Web chatCORE SlackSKILL SignalSKILL More channelsSKILLS inbound Router access · allowlist trigger · @mention role → toolset context · memory queue · one per chat turn Agent loop anthropic openai-chat openai-responses claude-cli calls results Tools read · write · edit · run send text, files db collections artifact · schedule filtered by role edits skills, then a git commit reads, writes Scheduler due tasks checked ≤ 60 s fired as turns due task ~/.attache · git repo skills/ SKILL.md, scripts memory/ USER.md, chats/ artifacts/ *.html memory, skills index attache.db (SQLite) messages · docs tasks · people sessions bridge Web server :8765 / chat, Home /a/<id> artifacts /api db, live events signed link → browser, phone skill, runs as its own process how the assistant edits its own skills
A message's path through Attaché. Dashed boxes are skills that run as separate processes. The orange path is how the assistant edits its own skills. With the 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

  1. 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.
  2. 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.
  3. 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.md stays out of anyone else's turn.
  4. The backend runs the loop. The model follows the pdf skill, runs its script through the script tool (which uses uv run), and calls send with the file.
  5. The core writes a send line to the sidecar's stdin, and the bot posts the PDF to the group with a caption.
§4

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
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
skills/mailbox/SKILL.md
---
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)
KindWhat the folder holdsExamplesWho edits it
KnowledgeOnly SKILL.md: rules, preferences, proceduresmailbox, todos, travel, tone of voiceYou and the agent
Scriptscripts/*.py with PEP 723 headers. uv run installs each script's dependencies on first use, outside the coremail (Outlook through COM, Gmail and Titan over IMAP), docx, xlsx, pptx, pdfBundled; the agent can write new ones
Channelchannel.py, started as a sidecar when attache run startsslack, signal, discord, matrixBundled; the agent can draft one
Templatetemplates/*.html and shared design tokens for artifactsartifact-kit: board, checklist, table, itineraryYou and the agent

How a skill changes itself

AttachéSlack, direct message
YouAmazon receipts should go to Finance/Amazon from now on, not Receipts.
AttachéDone. I moved 14 existing receipts and added the rule to your mailbox skill. Say “undo that” to put it back.
$ 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/ or memory/, the core commits with the request as the message. attache skills undo mailbox reverts the last change.
  • attache init copies the bundled skills into your folder. Upgrades to them arrive as a git merge, so your edits stay.
  • A bundled skill-writer skill lets the agent create new skills from a conversation, as nanobot's skill-creator does.
  • New abilities arrive the same way. Asked to “add Signal”, the agent drafts skills/signal/ with a channel.py that talks to signal-cli. You review the commit, and it starts on the next restart.
§5

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.

AccountBackendSign-inCalendarOff this PC
OutlookOutlook classic through COM, or Microsoft GraphCOM: Outlook's own. Graph: a one-time browser sign-inYesGraph: works as is. COM: this PC only
GmailIMAP and SMTPAn app password, which needs 2-Step VerificationNot in v1Works as is
TitanIMAP and SMTPYour Titan password, or an Application Password if 2FA is on. “Enable Titan on Other Apps” must be switched on in webmailNot in v1Works 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.

Checked on this PC, 19 October

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)
SetupNone. Uses the Outlook already signed in on this PCOnce: 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 runningStarted if neededNot needed
New Outlook, other machines, cloudNoYes
Calendar and tasksOutlook calendar and tasksCalendar and Microsoft To Do
Item IDsChange when an item movesImmutable IDs survive moves
Staying signed inOutlook's own90 days from the last use, renewed each time. Ends when you change your Microsoft password
Consent screenSays “unverified”: apps registered with a personal account can't be publisher-verified
Work accountsWhatever Outlook can openAn admin may need to approve the app
Where secrets liveNowhere newA token cache encrypted for your Windows user (DPAPI)
Skill dependenciespywin32msal, 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.

skills/mail/accounts.toml
[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"
One call, as the agent sees it
$ 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", …}]}
CommandsWhat they doRuns
doctor folders loginCheck each account is reachable and say why not, list folders. login is the one-time Graph sign-inAny time
inbox search readList, find and open mail. Bodies come back as capped plain text, marked untrustedOn its own
agenda tasksThe 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-readFile 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 yesOn its own, logged
draft-reply draft-newSave a reply or a new message in that account's DraftsOn its own, logged
send-draft respond create-eventSend a draft, answer a meeting invite, create an event with attendeesAfter your yes
Not builtRules, automatic forwarding, settings changes, permanent deletesNever
AttachéSlack, direct message
AttachéMorning. 31 new emails across Outlook, Gmail and Titan: 24 filed, 3 flagged, 1 needs a reply. Draft to Dan Price from your Outlook: “Thanks Dan, Thursday at 9am works. Please go ahead.”
AttachéSent from Outlook at 07:40. It's in Sent Items.

How sending works

  1. 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.
  2. 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.
  3. 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.
  4. 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 doctor says 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 install sets 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 doctor exists.
  • 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. doctor names the cause and asks you to run login again.
  • Gmail revokes app passwords whenever you change your Google password. doctor spots 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.
Off this PC

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.

§6

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.

AdapterSpeaksCoversTool loopLines
anthropicMessages APIClaude API, and any Anthropic-compatible base_url: OpenRouter, Ollama, LM Studio, llama.cpp, vLLM, a LiteLLM proxyAttaché~70
openai-chatChat CompletionsOllama, vLLM, LM Studio, llama.cpp, OpenRouter, Groq, Azure v1Attaché~90
openai-responsesResponses APIOpenAI reasoning models with toolsAttaché~60
claude-cliclaude -p, stream-jsonClaude Code on your login or an API key, with its own tools, sandbox and skillsClaude Code~100
codex-cli latercodex exec --jsonCodex on a ChatGPT loginCodex~80
attache.toml
[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"
agent.py, the whole shared loop
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. anthropic and openai together 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_effort is none, so OpenAI's own reasoning models need Responses. Local and hosted compatible servers mostly speak Chat Completions.
  • How claude-cli works. It runs claude -p --output-format stream-json --verbose --resume <session> in a folder outside your home, keeps one Claude session per chat, and points --mcp-config at 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. The claude-agent-sdk package 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-cli on your login for your own chats, claude-api with a key for group chats. A chat can still name its own endpoint. If ANTHROPIC_API_KEY is set, claude -p uses it instead of your login.
§7

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.

localhost:8765/a/todo-boardexample data
Bridge calls
    What generated pages call
    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

    1. The agent calls artifact(name="trip-checklist", html=…); the page declares <meta name="attache-collections" content="trip">.
    2. 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.
    3. 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.
    4. 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 forMade byLives in
    An answer or a listThe model, as a chat messageCore
    Markdown or CSVStandard libraryCore
    An interactive pageartifact tool and bridgeCore
    A PNG or PDF of a pageChrome or Edge already on the machine, headless. Saves the 150 to 200 MB Playwright browser downloadrender skill
    A Word documentpython-docx through PEP 723docx skill
    A spreadsheetopenpyxlxlsx skill
    Slidespython-pptxpptx 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.

    §8

    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. 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. 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. 3 · ROLE

      Who is asking?

      Owner, member or guest. The role decides which tools the turn gets.

    FamilySignal group · You, Sam, Jo, Attaché
    Sam@Attaché what time is our ferry on Saturday?
    Attaché09:40 from Portsmouth. Check-in closes at 08:55, so leave by 08:00.
    Jo@Attaché can you send the packing list as a PDF?
    AttachéPDFpacking-list.pdf · 2 pages
    You@Attaché make us a shared checklist for the trip so everyone can tick things off
    Attaché PassportsFerry ticketsPhone chargersSwimming kitTrip checklist · 14 itemsTap to open. The link expires in 7 days.
    Sam@Attaché read me Mum's last email
    AttachéEmail is private to the owner, so I can't read it out here.

    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 accessOwner (you)MemberGuest
    Answers in chatYesYesOnly if the chat is public
    Files and artifacts for this chatYesYesRead-only links
    To-dos and remindersAll listsTick off and add items on lists shared with the chat; own remindersNo
    Email, calendar, other chats' memoryYesNoNo
    Send to other chatsYes, after confirmingNoNo
    Shell commandsYesNoNo
    Edit skillsYesSuggest, you approve in your DMNo

    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.

    SlackSignal
    Best forYour own DM and work channelsFamily and friends' groups
    Setup on this PCCreate 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 URLNot needed (Socket Mode)Not needed
    StandingOfficial API. A single-workspace internal app fits Slack's API termsNo official bot API. signal-cli is unofficial but long tolerated. Signal's terms forbid bulk and automated messaging
    Who can joinMembers of your workspace. The free plan allows no guestsAnyone on Signal
    ApprovalsButtonsA 👍 or 👎 reaction, or a reply
    PrivacySlack stores messages. Not end-to-end encryptedEnd-to-end encrypted
    UpkeepLowsignal-cli has to stay under three months old or it can stop working
    Approval requests, the one addition to the protocol
    # 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.

    AttachéSlack, direct message
    AttachéJo asked in Family (Signal): “always send the shopping list as a checklist”. Update the shopping skill?
    AttachéApproved by you. I updated the shopping skill (commit 7d02e1f) and told the Family group.
    §9

    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.

    SurfaceJobBuilt with
    CLISet up and operate: init, run, chat, skills, service, doctorargparse
    Local web pageChat with history, open and pin artifacts, the Home viewOne HTML file and Starlette at localhost:8765
    Chat appsDaily use from your phone, and group chatsChannel 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.

    §10

    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.

    ModuleJobCode linesShare
    cli.pyinit, chat, run, doctor, skills, skill135
    app.pyWiring, the two servers, clean shutdown97
    config.pyattache.toml and its checks, init_home, restrict184
    credentials.pyExplicit environment or destination-bound OS keyring lookup111
    settings.pyOwner settings projection, validation, atomic saves and recovery345
    store.pySQLite: messages, docs, tasks; the db rules160
    router.pyAccess, roles, fencing, prompt, limits, turns, page turns233
    agent.pyShared tool loop115
    types.pyShared types93
    backends/anthropic.pyMessages API56
    backends/openai.pyChat Completions and Responses82
    backends/claude_cli.pyclaude -p, sessions, transcript134
    backends/__init__.pyPicks a backend25
    tools.pyTools by role, path confinement, ask, request, mail approvals, background scripts314
    skills.pyDiscovery, index, git history94
    channels.pyChannel manager, questions and answers, terminal, sidecars280
    scheduler.pyDue-task loop, DST-safe cron44
    web.pyChat page, artifacts, bridge, SSE, MCP, attache.ask, share links345
    Totalplus 3 lines in __init__ and __main__2,850
    pyproject.toml
    [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"
    tests/test_budget.py
    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.

    §11

    Roadmap

    1. 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 todos skill and the Home artifact.

      Done whenuvx attache gives a working assistant on localhost with to-dos and reminders.

      Built: pull request #28, version 0.0.1.

    2. PHASE 2Messaging

      The sidecar protocol with approval requests, the slack and signal skills, router policy and roles, file sending, the render skill 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.

    3. PHASE 3Email and files

      The mail skill with its three backends: Outlook classic through COM or Microsoft Graph with OAuth, and IMAP for Gmail and Titan, with approved sends. The mailbox knowledge skill and a 07:30 triage task. Then docx, xlsx and pptx skills, sharing through Tailscale Funnel, attache.ask from artifacts, and attache 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.

    4. 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, the codex-cli adapter.

      Done whenthe same package runs on a small cloud machine, with your folder synced by git.

    §12

    Risks and decisions

    RiskMitigation
    The core growsThe budget test D9, features as skills D2, and a CONTRIBUTING rule copied from nanoclaw.
    A message or email tries to steer the assistantIncoming 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 OutlookClassic 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 blockeddoctor separates expiry, consent and policy problems and asks you to sign in again. On this PC, COM stays available as a fallback.
    Mail passwords leakStored 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 changeattache 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 numberA dedicated number, low volume, and replies only to people who write to it, plus your own reminders.
    Slack free plan limitsAttaché keeps its own message history in SQLite, so 90 days of visible Slack history doesn't limit its memory.
    Prompt injection in groupsToolsets by role, the trifecta rule, fenced context D7.
    Shell access on the hostrun 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 termsGroup chats default to an API-key endpoint.
    Weak tool calling on local modelsTolerant argument parsing, a list of tested models, and a model choice per chat.
    CLI output formats changeParse only assistant text and the final result, and test against recorded fixtures. Pin versions.
    Windows differencesCopies 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 mail skill.
    • 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), then mail.py login outlook.
    • Phase 3, Titan: “Enable Titan on Other Apps” in webmail, and an Application Password if 2FA is on.
    Next step

    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.

    §13

    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.