Law Flow OS: eight AI agents and a coordinator that barely thinks
How I run Law Flow with eight AI agents on Claude Code: an event bus, a LangGraph coordinator on AWS in Frankfurt and approvals that no agent can grant.
Law Flow OS is the operating system of my one-person consultancy: eight AI agents with fixed responsibilities, a coordinator on a small server in Frankfurt and a versioned folder of files as shared memory. The agents work when a signal arrives or when I call on them, and none of them can send an email, publish a post, delete anything or trigger a payment, because none of them has a tool for it. The coordinator calls a language model in exactly one place; its real job is to prove that a run actually did something.
Descriptions of agentic operations usually deal with what the agents can do. In a business where one wrong sentence in the wrong email costs trust, two other questions matter more: what the agents cannot do, and how you can tell that they did their work. Law Flow OS uses the same access path that I set up for law firms, which is Claude via Amazon Bedrock (the service through which the AWS cloud platform provides language models) in a dedicated AWS account in Frankfurt.
What Law Flow OS is, technically
Law Flow OS consists of six building blocks, each with one job.
| Building block | Job | Technology |
|---|---|---|
| Business vault | Memory: rules, knowledge, decisions, status, log | Markdown files in Git, origin on my own server |
| Google Workspace | Interface for mail, appointments, contacts, pipeline and tasks | Gmail, Calendar, Contacts, Sheets, Tasks, Drive |
| Event bus | reports signals, decides nothing | Apps Script, API Gateway, Amazon SQS |
| Coordinator | starts one run per job and checks it | LangGraph service on an EC2 server |
| Eight roles | do the work | Claude Code, one file per role |
| Google connector | gives the roles their tools | custom MCP server with 14 tools |
Everything runs in a dedicated AWS account in the Frankfurt region. Model calls go exclusively through Bedrock with an EU inference profile (an address behind which AWS distributes requests across data centres in several EU regions). A rule in the account’s permission management forbids any call outside these regions, and I have tested it negatively, that is, with a call that has to fail; how that works is described in the post on EU hosting that is technically enforced. What gets logged is that a model was called, never what the call contained. The operation needs no Claude subscription, which also means it lacks everything such a subscription brings: ready-made connectors to Google, scheduled routines in the cloud, built-in web search and notifications on my phone. What I need of that I built in my own account, and a ready-made service would have taken this operational load off my hands.
The division of labour follows a picture from the trades: the coordinator is the office, Claude Code is the workshop. The office accepts jobs, checks them, hands them out and verifies whether something got done. The work happens in the workshop, that is, in Claude Code (Anthropic’s program that lets a model read and write files and use tools), which the coordinator starts fresh for every job, each time as one role. I call one such run a sweep. Office and workshop read the same role file; if there were two versions of a role, their security rules would drift apart over time as well.
Every piece of information has exactly one place
Before the first line of code there was a list of things to cut. Contacts, pipeline and tasks live in Google instead of Notion, appointment booking uses the Google Calendar booking page instead of Calendly, and the rented server that used to run automations is gone. What remains is a single place for each kind of information where the truth lives.
| Information | Place of truth | What therefore does not exist |
|---|---|---|
| Contacts | Google Contacts | contact lists in the vault |
| Pipeline | a Google Sheet “Pipeline” with stage (sales phase) and follow-up date, one row per contact | pipeline tables in the vault |
| Tasks | Google Tasks, one list per area | to-do files |
| Mail, appointments, documents | Gmail, Google Calendar, Drive | copies in the vault |
| Invoices | a Google Sheet “Invoices” as the bookkeeping list | amounts from memory |
| Rules, knowledge, decisions, status, log | business vault | operational knowledge in chats |
| Client matter content of the law firms I work for | only in the respective firm’s account | matter content in my operation, in prompts or in logs |
The last row is the boundary everything else depends on. Business contacts, meaning names and firms from my pipeline, may go into the vault. Content from client matters never appears there, not even file numbers, so client secrets protected by Section 203 StGB (the German Criminal Code provision on breach of professional secrecy) never enter my operation in the first place.
The vault is memory and org chart at once
The business vault is a Git repository (a folder in which every change is recorded with its time and a description) full of Markdown files (plain text files with a few formatting characters), which I read and edit on my Mac in the note-taking app Obsidian. It contains what the business needs to know: the pricing model, the sales playbook, client profiles, step-by-step guides for setting up law firms, decisions with their reasoning and the alternatives rejected, key figures, the state of every role and an operations log that is only ever appended to.
The folders are numbered from 00_betrieb to 90_archiv, and only one role writes to
each folder; all of them may read. sales writes to 20_sales, delivery to 30_kunden and
40_delivery, content to 60_marketing and finance to 70_finanzen; specialist
knowledge reaches 80_wissen only through a dedicated transfer path. The folder structure
thus settles responsibility: to find out which role was allowed to change a file, look at
its path. The entry file, the role files and the tool configuration are written by me
alone.
Every role leaves two traces, and only its own: its status file in 00_betrieb/status and
its line in the shared log. That is why the sweeps may commit (record a change with a
description) directly to the main branch without getting in each other’s way. Two roles
never touch the same status file, and for the log, Git is configured to simply accept
simultaneous appends from both sides.
The vault’s origin is a bare repository (a Git store without a working copy, just the data) on the server in my own account, not on GitHub. My Mac and the coordinator work on copies and push their changes there. A script on the server rejects any push that tries to rewrite history or delete the log. Every night a complete copy goes as a single file into storage with Object Lock (a lock that makes files immutable for a set period) in COMPLIANCE mode: for 35 days nobody can delete it, not me with full permissions and not AWS either. A second, independent backup is written by my Mac to Google Drive after every commit.
Alongside the business vault I keep a second, private vault for study, research and experiments. Knowledge flows between the two in one direction only: the librarian role condenses a page from the private vault into a new page in the business vault, checks it for personal data and notes the source. Nothing is copied in the process, and nothing flows back.
Eight roles share the work
Each of the eight agents is a role with a job description: a Markdown file whose header defines which tools the role has, and whose text says what it reads, where it writes, what it must read first before any work, and which actions always stay with me. The roles reflect what a young company would hire staff for, not what features a piece of software has. One sentence from the vault’s entry file applies to all of them: a role does not ask for tools outside its list; it names the gap in its status file.
| Role | Trigger | Writes | Stays with me |
|---|---|---|---|
| assistant | new email, appointment in under two hours, request | call briefs, email drafts, pipeline row | sending, moving or cancelling appointments, creating contacts |
| sales | change in the pipeline, follow-up due, request | drafts of offers and follow-ups, pipeline upkeep | sending an offer, any price outside the pricing model, stage “active engagement” or “Lost” |
| delivery | stage “active engagement”, build work on the Mac | checklists, guides, code branches | merging into the main branch, any action in a client account, sending the kickoff email |
| coo | request /standup | status overview, key figures | no external action planned |
| content | request | drafts for LinkedIn, blog, YouTube | publishing |
| finance | request | invoice drafts, bookkeeping list | issuing invoices, sending reminders, payments |
| librarian | request | knowledge pages, index, archive | deleting, archiving, transfers involving personal data |
| itops | request, Mac only | list of findings | any change to AWS, DNS, Cloudflare and servers |
assistant is the front office. Every five minutes the bus reports unread emails that did not come from me, and every appointment that starts in less than two hours. For an email, assistant reads the thread through the Google connector, classifies it, sets a label, creates a reply draft if needed and updates the contact’s pipeline row. Before an appointment, the role first reads the sales playbook and then writes a call brief (a preparation page for the conversation); the debrief goes underneath it after the call, once a transcript exists and I ask the role for it. Much of this work is unspectacular: an automated report on email authentication for my own domain gets a label and nothing else, and “no action needed” is a complete result. The tool list is cut so narrowly that assistant can write a pipeline row but not read one. The role records this as a blocker in its status file instead of looking for a workaround.
sales maintains the pipeline. When I change the stage or the follow-up date of a row in the sheet, a sweep starts for that contact. In addition, the Apps Script checks every morning at 8 a.m. whether follow-ups are due and then sends a single job with all the names; one job per contact would close the coordinator’s rate limit after six rows. The role reads the row, the contact, the calendar and the pricing model, moves follow-up dates, creates follow-ups as Gmail drafts and calculates offers according to the pricing model, also as drafts. The role file contains a sentence that says more about pricing discipline than any template: never price by effort, never discount because the setup is quick. sales works through a backlog of overdue follow-ups in one pass and sets a new date for each row; where an email is due, it is waiting for me as a draft afterwards.
delivery carries out what has been commissioned. When I set a row to the stage “active engagement”, a kickoff sweep starts: delivery checks the client folder, the setup checklist and the next step, creates tasks and prepares the kickoff email as a draft. The setup itself at a law firm runs interactively on the Mac and never in an automatic sweep, because every action in a client account needs my approval. Building follows a fixed pattern: helper agents write the code, each in its own working copy, and before every merge a reviewer checks the work without knowing its history. A large part of Law Flow OS was built this way, in interactive sessions of the delivery role.
coo provides the status overview. The role reads the eight status files,
the log, the key figures, the pipeline totals and the open tasks and turns them into one
page on the state of the business. It never acts externally; apart from two read tools
for the sheet and the tasks, it has no connection to Google. Its file demands two things:
a status file that is older than the last signal for its role appears in the overview as a
failed sweep and is not smoothed over, and a missing number is named as a gap, never
estimated. For a quick look, coo is not needed at all. /standup is a small script that
reads and displays the eight status files without a model call; only /standup live first
runs all eight roles in parallel and accordingly costs eight model runs.
content writes drafts for LinkedIn, the blog and YouTube on request,
following a style profile distilled from my previous writing. The role file calls the
style “a rule, not a suggestion”. The role creates blog posts as a separate branch in the
website repository, marked draft: true, and nothing is published until I switch that
flag. content has no Google tools, and no access to LinkedIn either.
finance prepares the bookkeeping. On request, the role creates invoice drafts from a template, maintains the bookkeeping list in the “Invoices” sheet, tracks running costs and subscriptions and proposes payment reminders. The role takes amounts only from the sheet, never from memory and never rounded. Issuing invoices, sending reminders and every payment stay with me. I have not yet decided whether a sheet will do as a bookkeeping list in the long run or whether I need a tool that meets the GoBD (the German principles for proper electronic bookkeeping).
librarian keeps the vault in order. The role checks it for dead links, outdated statements and missing index entries, moves finished material to the archive and runs the transfer path from the private vault. Nothing is ever deleted in the operation; even moving something to the archive needs my approval, as does any transfer of a page containing personal data.
itops is the technical audit function. The role checks hardening, backups and certificates in the AWS account, at Cloudflare and on the server, and creates a list of findings with a proposed fix for each row. The role may not change anything: whoever finds a problem does not fix it, so that the list stays honest and the check stays repeatable. After larger rebuilds, four such checks run simultaneously and read-only, one each for the AWS account, the server’s operating system, the Mac with the code repositories and the external attack surface. itops is the only role that runs only on the Mac, because it needs access that an automatic sweep does not get. Before any statement about AWS, the role first clarifies which account is meant; the operation never touches law firms’ accounts, neither reading nor writing.
Three helpers work without their own role file
The coordinator itself contains a model call that assigns free text to a role. For larger builds, delivery starts helper agents, each in its own worktree (a second, parallel working copy of the same code), so that two builders do not switch each other’s state out from under them. And every build is checked by a reviewer: a fresh model run for each review round, which reads the result without knowing its history, like a proofreader seeing a text for the first time. It is never continued, because a reviewer who watched the build reads along with the builder’s assumptions. From the second round on, it gets only the changes since the last sign-off and the conditions from the previous round; otherwise every round renegotiates what the first one already decided. The builder may refute findings, but only with a measurement.
A sweep ends with two traces
The ending is governed by a shared work instruction, a so-called skill (instructions that Claude Code loads when needed): the role overwrites its status file with the time of the sweep, the trigger, what was done, what is waiting for approval, its blockers and the next step, and it appends a line to the log. Empty sections get a placeholder, nothing is left out, and “nothing found” is written just like a full sweep. Whatever is waiting for approval appears in both files, because the status overview reads the status file and the log is the history.
The event bus reports and decides nothing
In Law Flow OS no role runs on a schedule. There are two triggers: a signal from the event bus or a request from me. The bus is a Google Apps Script (program code that runs directly inside Google’s services), attached to the “Pipeline” sheet, that detects four signals.
| Signal | Checked | Job goes to |
|---|---|---|
| stage or follow-up date changed in the sheet | on every edit | sales; for “active engagement” also delivery |
| unread email, not from me | every five minutes | assistant |
| appointment starts in under two hours | every 15 minutes | assistant |
| follow-ups due | daily at 8 a.m., no job if nothing is due | sales, one job with all names |
With an email, only the thread ID and the subject go into the job, never the content. The role reads the thread itself through its Google connector. This keeps email content out of the queue and out of all of the bus’s logs; it is data minimisation within the meaning of Art. 5(1)(c) GDPR, applied to a queue.
What follows from a signal is defined in the role file; the script only reports that something happened. So that an event does not fire more than once, the script remembers every reported event for a period that fits the signal: three hours for an appointment, two for an email, 15 minutes for an edit in the sheet, just under a day for the follow-up. Each pass sends at most 20 jobs, and at most five from sheet edits, so that a pasted list does not trigger two hundred sweeps. A separate bolt guards against feedback loops: if a role has just changed the row itself, no new signal results from it, and the trigger does not see changes that come through Google’s API anyway.
Every job goes to a single entry point at AWS, an API Gateway (AWS’s front door for web requests), which places it straight into the queue without any program of its own. Three locks sit in front of it. Without the right key the entry point refuses, and a usage plan limits it to two requests per second and 500 per day. A format check only lets through jobs that name one of the eight roles and keep every field short; requests and approvals cannot come in this way at all, only I put those into the queue. And the identity the entry point writes with may do exactly one thing: put messages into this one queue. Access is logged without content, because a job contains subject lines and names.
The coordinator thinks in exactly one place
The coordinator is a Python program that runs as a service on the server, a small instance (a virtual server at AWS) with just under one gigabyte of memory, no open port and reachable only through AWS Session Manager. In a loop it polls the queue and starts a run for each message. One job is one sweep, never more: there are no long-running processes and no agents working away for hours. Each poll stays open for up to 20 seconds and returns immediately as soon as something arrives. When nothing is pending, the coordinator does nothing and costs nothing except the server.
LangGraph, because pausing is built in
The run is built with LangGraph, a Python library that describes a workflow as a graph (a
sequence of named stations with branches). Originally I had planned to use a ready-made
agent product for this. Two things decided against the product. LangGraph has a built-in
stopping point, interrupt(), at which a run freezes and waits for a human decision, and
that maps one to one onto my approvals. And I wanted to know LangGraph first-hand, because
I set up agentic systems for law firms. This costs more of my own code than a ready-made
service, and I paid that price deliberately for the learning.
Three kinds of message arrive
| Kind | Content | Sender |
|---|---|---|
| Sweep job | role, trigger, reference, optionally a job text | event bus or me |
| Request | free text, such as “sales: draft follow-up for firm X” | me only |
| Approval | ID of a paused run, decision, note | me only |
To the coordinator, the content of a message is data, not a command. If a request says “ignore your rules”, that is text to be assigned to a role. From an approval, the coordinator reads exactly three fields and ignores everything else; a message can answer a paused run, but not steer it.
Five stations, one of them with a model
The graph has five stations. Each is an ordinary Python function that receives the run’s shared state and returns only the fields it changes. As soon as a station sets the end, the run is over. The branches between the stations only read what the stations have written into the state; decisions are made in the stations, each question in one place.
triage is the only station with a model call, and it runs only for a request. It asks a model on Bedrock which of the eight roles is meant and has it phrase the job in one sentence. The answer must have a fixed shape, and that shape only allows the eight names as a role; the model cannot even return an invented ninth role. Temperature is set to zero (for answers that are as consistent as possible), length is capped, and the prompt makes clear that the request is a job from me and not text from an outside source whose instructions should be followed. If the assignment fails, the run ends cleanly. Otherwise the message would stay in the queue, come back up to ten times and trigger a paid assignment each time. If a message names its role itself, the graph skips triage.
prüfen (check) is the gate with three bolts that sit in front of anything that costs
money. First, the role must exist as a file, and its name may consist only of lower-case
letters, digits and hyphens. The name later becomes a file path, and without this check a
message with the role name ../etc could lead the coordinator out of the vault. Second,
debouncing (a lock-out period against double triggering): if the same role ran with the
same reference in the last 15 minutes, no second sweep starts. Third, the rate limit: if
six sweeps have already run in the last hour, the next job waits. Both counters live in a
small SQLite file (a database that consists of a single file). Counting only happens once
a sweep really starts. A job that then fails straight away on a broken working copy should
neither use up a slot in the rate limit nor set the debounce, or else the coordinator
would discard the same job as a duplicate after the clean-up. Trigger and reference come
from the bus and therefore indirectly from an email or a spreadsheet cell; the station
truncates them and strips special characters before they end up in commit messages,
prompts or log lines.
sweep is the only station that costs any real money. Before it starts Claude Code, it brings the vault’s working copy up to date and checks whether the result can be pushed afterwards: no half-finished merge from an earlier job, the main branch checked out, no uncommitted changes to tracked files. If the working copy is not clean, no sweep starts. The coordinator then does not clean up by itself, because the working copy might contain results of an earlier sweep; it refuses the work and says why.
Then the station builds the job. It always starts with the timestamp of the sweep, because the role has no clock: it may not run system commands, so it cannot read the time anywhere and would guess it. Outside text from the bus is capped at 4,000 characters. Claude Code is started as the requested role, with a fixed model, Sonnet 4.6, and with three settings that say more about security than any policy. No sweep gets a shell (the server’s command line), whatever its role file allows. The coordinator opens the door to the Google connector; which tools are visible behind it is decided by the role file alone. And the role may change files without asking, because in an automatic sweep there is nobody there to answer a question.
The sweep has a time limit of 25 minutes. The queue keeps a collected message hidden for 30 minutes so that nobody processes it in parallel; if a sweep took longer, the message would reappear in the middle of the sweep, and two sweeps of the same role would work in the same directory. If a sweep exceeds the limit, the coordinator ends not just Claude Code but the whole process group (the program together with all programs it has started itself), first by asking it to exit, then forcibly.
After the sweep, the station commits only what has been created in the ten content
folders. The entry file, role files and tool configuration are my business; a sweep that
changes something there should not be able to commit it, or else a role could write new
permissions into its own file. For the same reason, instruction files stay out even inside
the content folders: a file called CLAUDE.md that a sweep creates in a folder would be
loaded as instructions by every later session, on the server as well as on the Mac.
Whatever a sweep leaves outside the permitted folders, the station puts into Git’s stash,
tagged with the job’s ID, and then checks the working copy itself to see whether it really
is clean, instead of trusting the command’s success message. Then it pushes the commits to
the origin. If a change from the Mac has arrived there in the meantime, it rebases its
commits onto the new state once; if a conflict remains, it aborts and reports it instead
of quietly mixing together something nobody has checked.
abschluss (wrap-up) is the cross-check and the core of the coordinator. A language model can run through without errors and have done nothing, and a program’s return value proves neither one nor the other. The station therefore demands traces in two independent places. The role’s status file must carry a timestamp that is not earlier than the start of the job, and the log must contain a heading from this role that did not exist before the sweep. The station compares the list of headings before and after the sweep, because Git sometimes slots simultaneous appends from the Mac in between, so the last line is not enough. If either trace is missing, the coordinator itself writes the line “sweep failed” into the log, with reason, duration and return value. After that, the station looks under “Waiting for approval”, and only for new items: roles carry open approvals across many sweeps, and if every known item paused the run again, the run would stall after every sweep.
gate is little code and the reason I chose LangGraph. interrupt() pauses the graph in
the middle of the station. LangGraph saves the entire state in a second SQLite file on the
server’s disk and tells the coordinator what it is waiting for; the coordinator writes the
run’s ID into its log together with the ready-made command line I can reply with. Hours or
days later I put an approval into the queue. The coordinator loads the saved state, and
interrupt() returns my answer at the same spot, as if it were an ordinary, just very
slow function call. The pause survives a restart of the service and of the server. What
happens afterwards is deliberately unspectacular: the station writes a receipt into the
log saying that I approved or rejected, along with my note. It sends nothing and does not
carry out the approved action either; I do that myself, in Gmail, in the sheet or in the
terminal.
Every message is either deleted or stays in the queue
After each run, the loop only decides what happens to the message. If it is not a valid JSON object (a structured text format), it is deleted, because it will never work. If processing crashes, the message stays and comes back after the lock-out period expires; after ten failed deliveries it moves to a dead-letter queue (a tray for jobs that have failed repeatedly) and remains visible there for 14 days instead of disappearing silently. If the rate limit kicked in or the working copy is unusable, the message also stays, but becomes visible again after only ten minutes; with the full lock-out period of 30 minutes, a valid job that merely had to wait would count as undeliverable after five hours. In all other cases the job is done, and the message is deleted.
A run’s ID includes milliseconds. That looks like pedantry and is a lesson from operations: with IDs accurate only to the second, two messages from the same second got the same ID, the second job hit a run that had already finished, never started and reported back the result of the first. Errors like this look like success and are therefore noticed late.
The housing locks the service in
The service runs as its own system user without admin rights, started and supervised by systemd (Linux’s service manager). For the service, the entire file system is read-only, except for the disk holding the vault, the counters and the saved runs, and a private folder for temporary files. Neither the service nor any program it starts can ever gain more rights than it had at start-up. Memory is only softly limited: when it gets tight, systemd throttles the service instead of killing it, because a hard stop might hit a sweep after twenty minutes of paid work. If the start fails five times in five minutes, systemd gives up instead of restarting the service endlessly, and without a mounted disk it does not start at all.
Access keys are not stored anywhere. The server gets its permissions from AWS itself, via an instance role (an identity AWS assigns to the server), and the long-lived credential for Google is kept in the account’s vault, Secrets Manager. After every deployment of a new version, a self-test checks whether the queue is reachable, whether Claude Code is installed, whether the working copy is clean, whether Bedrock responds via the instance role and whether eight role files are in the vault. The Bedrock probe is a real call with the word “ping” and at most five tokens (billing units for text) of answer, because only a real call proves that role, region and region lock fit together.
The tests check the failure case
The coordinator has a single test file. It builds a small vault with its own origin in a
throwaway directory and puts a short script on the search path in place of Claude Code; the
script behaves like a role but needs neither a model nor money. Almost all the checks are
failure cases: a sweep that writes nothing while an old log line from the same role fakes
a completion; the same job twice in a row; a role called ../etc; a hanging sweep that
deliberately leaves behind a child process, which the test then wants to see dead; a real
merge conflict, where the test proves that Claude Code was never started; a full rate
limit; and the loop itself with a mocked queue. The tests do not prove that the roles do
good work; no check can show that. They prove that in a failure case the coordinator would
rather do nothing and say so loudly than do something wrong and stay silent.
Eight review rounds, each with a finding
The coordinator was built by helper agents following the pattern described under delivery, checked by a context-blind reviewer, a fresh one for each round. According to my operations log there were eight review rounds, and each one found a real bug: three ended in a rejection, five in an approval with conditions. That is a single build step in a single project and is no use as an error rate. What is revealing is the kind of findings. A cross-check during the move to the new disk would have read a read error as “zero commits” and overwritten the real data with an empty set. A merge conflict would have jammed the service permanently, because it would have left the half-finished merge lying around. A directory would have been writable for the service user, which could then have rewritten its own check script. And a copy command would have mixed a database file with someone else’s intermediate log. None of these bugs shows up in a trial run where everything goes smoothly.
No role can send anything
The security of Law Flow OS depends on several nets, each of which should hold on its own if another one tears. The first is the tool surface: I built the Google connector myself, as an MCP server (an intermediary that gives a model a fixed set of tools) with exactly 14 tools: search, read, label and draft in Gmail; read the calendar; search and read Drive; read, write and append to spreadsheets; search contacts; read task lists and tasks, and create tasks. Sending, deleting, creating appointments and creating contacts do not exist as tools. A ready-made open-source server would have brought those tools along, and security would then depend on a block list of tool names that can go out of date with the next version from someone else. Google’s own remote access relies on a browser login per session, which does not work on a server without a screen. Whatever no role can do, such as creating a new spreadsheet or deleting old contacts, is handled by a one-off script that I run myself on the Mac.
There is one limitation. The permission Google requires for labels and drafts technically also allows sending, and Google does not offer a drafts-only permission. Whoever got hold of the credential could therefore send past the connector. What is protected, then, is access to that credential: only the server’s instance role and my login on the Mac may read it.
Second, the tool list in the role file applies, and of all things a failure proved it: a trial run of the coo role ran for 96 seconds without errors and wrote nothing, because its list did not contain a single write tool. That was annoying and at the same time proof that these lists are not decoration.
The third net is the missing shell. A sweep reads outside content, that is, emails and spreadsheet cells, in which anyone can write anything. If it also had a command line, internet access and read access to the credential, an injected instruction would be a data leak in a single step. Without a shell, a manipulated sweep only has the tool surface of the Google connector, and that can neither send nor delete. The price: roles cannot run scripts during a sweep, not even read the time.
The fourth net is a hook (a check script that Claude Code runs before every tool call). It blocks tools whose names sound like sending, deleting, publishing or paying, destructive commands, and any reference to a law firm’s account. It is deliberately only a secondary net, because a sweep could change the configuration in its working copy and disable it; that is why nothing important depends on it alone, and why the coordinator never commits such changes. A security check showed how easily a secondary net tears. On the server, the hook crashed at start-up on a syntax that the older Python version installed there did not know, and for hooks a crash with that return value means “let it through”. Since then, every error in the hook blocks, and its self-test runs on the target machine instead of only on the Mac.
The fifth net is the hard limits in the account: the instance role’s permissions, which allow only what is necessary, the region lock for Bedrock, CloudTrail (the log of all actions in the account) with alarms, for example on a login with the root user (the account’s master key), and the undeletable nightly backup of the vault. The script on the server that refuses to rewrite the vault’s history is only a brake against mistakes; the barrier that holds is the backup.
Above all of this sit the approvals. The vault’s entry file lists them exhaustively, and
they apply to every role: sending, publishing, issuing invoices, sending reminders and
paying, deleting, setting a stage to “active engagement” or “Lost”, moving or cancelling
appointments, creating new contacts, merging into the main branch of a code repository,
any action in a client account, any change to AWS, DNS or servers, and any price outside
the pricing model. Instead of acting, the roles create drafts: a Gmail draft with the
label Agent/Entwurf (agent/draft), a file with the status “entwurf” (draft), a task
“Freigabe: …” (approval: …) or an item under “Waiting for approval”.
A job runs through the system in seven steps
- An email lands in the inbox. Within five minutes the Apps Script sees it and sends a job for assistant to the entry point at AWS, with thread ID and subject, without content.
- The entry point checks key and format and puts the job into the queue.
- The coordinator collects it; the prüfen station establishes that the role exists, that the same email has not just been processed and that the rate limit has room.
- The sweep station brings the working copy up to date and starts Claude Code as assistant, with a timestamp, without a shell and with the Google tools from the role file.
- The assistant role reads the thread, looks up the contact, creates a reply draft with
the label
Agent/Entwurfand updates the pipeline row; finally the role writes its status file and log line. The station commits what has been created in the content folders and pushes it to the origin. - The abschluss station finds both traces. Because there is a new item under “Waiting for approval”, namely the draft, the gate station pauses the run.
- I read the draft in Gmail, edit it, send it myself and put my approval into the queue. The log gets a receipt, and the run is over.
Where it works
In day-to-day use it looks like this: assistant writes call briefs before appointments and sorts out automated emails without making more of them than necessary, sales keeps the follow-up dates in the sheet current and leaves follow-ups as drafts, and delivery checks at kickoff whether the client folder and checklist are right. The status overview is built from eight status files without my having to ask any role, and a failed sweep appears as a line in the log instead of going unnoticed.
The coordinator contains almost no intelligence of its own, and that is exactly what makes it testable: every one of its decisions can be reproduced in a test without asking a model. Costs are distributed the way the design promises. The server is a small item; the operation costs money where a model works, that is, per sweep, and that point is limited by the bolts in the bus and in the coordinator. What other costs AI brings is covered in the post on what AI costs in a law firm.
Where it breaks
Most bugs sat at the seam between what a role writes and what the coordinator reads. One role entered only the date in its status file, because it has no clock, and the cross-check reported a failure even though the sweep had done its work. Another added a note in brackets to the heading “Waiting for approval”, and the pause never happened, because the coordinator was looking for the exact line. A third wrote “No open approvals” instead of the agreed placeholder and thereby paused the run although nothing was waiting. The lesson applies generally: when a model writes something that a program reads, the program has to be lenient at the edges, and whoever demands an exact value from a model has to give it the source for that value.
Twice a setting looked right and had no effect. The approval of the Google tools was correctly entered in the vault’s configuration file, but in Claude Code’s automatic mode it was not applied; two trial runs of assistant failed because of it, until the coordinator passed the approval as a start option. And the secondary net was dead until a security check found it. Since then, the operation follows what I know from hardening an account: a setting is only proven once a test sees it take effect.
Once, an update to the server’s build description (a CloudFormation template from which AWS builds the environment reproducibly) replaced the instance instead of modifying it. The template looked up the newest operating system image on every build, there was a newer one that day, and with the old disk the vault’s origin was gone. The complete copy on my Mac restored it in a few minutes; a reviewer had named exactly this risk as an open item that same morning. Since then the image has been pinned, every change to the template first runs as a preview that aborts as soon as the server would be replaced, and the vault lives on its own disk that survives a server replacement.
The design has limits that I know and accept. The coordinator processes one job after another; parallel sweeps would require more than one of them writing to the working copy, and that would be a rebuild, not a setting. The bus detects emails with a delay of up to five minutes, and the Apps Script quotas limit it long before the usage plan at the entry point kicks in. There is no access via a messenger; I put requests and approvals into the queue as a small message from the terminal, which works for one person and not for a team. And because law-flow.de has a single mailbox, all roles act towards Google as the same user; they can be told apart by labels and the “Changed by” column in the sheet.
The model is one generation older than planned: a newly created AWS account does not automatically get the newest Claude generation, so the sweeps run on Sonnet 4.6. The model catalogue shows what a region can do; only a real call shows what your own account is allowed to do.
Finally, the undeletable backup has a legal downside that I accept deliberately. Sweeps write business contacts into the vault, so names and firms are also in the nightly package that nobody can delete for 35 days. If a data subject requests erasure of their data under Art. 17 GDPR, I delete it immediately in the vault and the working copy; in the package this is technically only possible once the period has expired. I consider this justifiable, because the backup is the last line of defence against someone with full permissions in the account and contains only business contacts, and I have documented the period as a technical and organisational measure. That is my assessment for my own operation, not legal advice, and I am happy to be corrected here.
What this means for law firms
Law Flow OS is a one-person operation without client matter data and therefore not a blueprint a law firm should adopt one to one. Some components can be transferred, however, because they reflect what law firms have always known.
A role file is a job description with signing authority. It defines what a role reads, where it writes and which actions stay with a human, in a law firm that means with the lawyers. If you deploy an agent in your firm, you should be able to put these three points in writing before the first run starts. If you cannot, you do not yet know exactly what the agent is supposed to do.
A missing tool is a bolt that needs no maintenance. An agent that may not send client correspondence should not have a tool for sending; an instruction in the prompt and a block list are secondary nets. This applies especially where an agent reads outside text, because every brief from the opposing side and every incoming email can contain an instruction that does not belong there.
Proof belongs in the workflow. The cross-check of status file and log line follows the same idea as the completion note in a deadline diary: a deadline without a note counts as open, a sweep without a trace counts as failed.
With client matter data the situation changes. As soon as agents read matter content, the firm must carefully select every service provider in the chain under Section 43e BRAO (the Federal Lawyers’ Act provision on outsourcing) and bind them to confidentiality, and every disclosure must stand up to the standard of Section 203 StGB. Then the system belongs in an account of the firm, on an access path that meets these requirements, and under a confidentiality agreement under Section 43e BRAO.
And the start can be small. Law Flow OS started with assistant, a role that reads and writes drafts. For law firms there is a lot to be said for likewise starting with a narrow, recurring task, for example from non-billable work, and with the simplest architecture that fits.
What I take away
Almost all the decisions that proved themselves in building Law Flow OS were cuts: no sending, no shell in the sweep, no schedule, no second source of truth. I trust the coordinator because it decides almost nothing itself. It accepts a job, checks it, starts a role, demands traces and pauses when I am needed. The roles are allowed to be clever; the coordinator only has to be able to prove what they did.
A tool that does not exist does not need to be forbidden.
The content of this blog was created partly with the support of AI.