Docs Decisions
0023 · Automations reach outside through actions the host carries out: connections, email to members, and a model
Status: Accepted · 2026-10-24. It amends 0007 (no network) and 0010 (no LLM). Only an instance's management creates connections, email goes to members only, and the model runs on a key of the instance's own. Step 3, the model, was frozen on 2026-10-25: workflows that need a model run on the customer's own AI (Claude, ChatGPT, or a coding agent in CI) and write back over MCP, so 0010 holds in full again.
Context
An automation can share, tag, notify inside CoDNA and suggest links. The workflows people ask for reach further: post to Slack when a runbook changes, email the team a weekly list, summarise what changed this week. automations.md deferred outbound webhooks as "the moment the sandbox stops being airtight", and 0010 keeps CoDNA from calling a model at all.
Giving the script functions to call — fetch(), sendEmail(), askModel() — would break three
things the design depends on:
- Time. A run has 100 ms and no event loop. A web request or a model call takes seconds. Waiting would mean asynchronous scripts and workers held open: what the sandbox was built not to have.
- Trying it first. A dry run can say "would post to #ops" only because a post would be a proposal. A script that calls out itself either really calls out on a dry run, or cannot be tried.
- Secrets. A key inside the sandbox can be logged, returned or sent wherever the script can reach.
Decision
The sandbox does not change. It has no network, the script stays synchronous within its limits, and it still only proposes. The new reach is three kinds of action that the host validates with the rest and carries out after the run commits, through the outbox (0009):
actions.post('slack-ops', { text: `${doc.title} changed` })
actions.email('group:ops', 'Runbooks to review', body)
actions.ask('Summarise what changed this week', { documents: refs, into: 'output:reports/weekly' })
Each is switched on per instance
Three capabilities, connections, email and model, are each off until management switches
them on for the instance (a manage-API route, the codna command, and the console's switches).
They are kept in settings. While one is off, the action is refused like any other refusal, with
the reason "this instance has email switched off", and a dry run says the same. Switching one off
stops deliveries not yet made.
Connections: where an instance may post, created by its management
A connection is a named destination that only management creates, changes or deletes. Each change is recorded in the management audit. It has:
- a name, which scripts use:
slack-ops; - a kind:
webhook, which POSTs the payload as JSON, orslack, which wraps a string as{"text": …}and sends an object as it is; - an https URL, and optionally one secret header. Both are encrypted at rest with
CODNA_SECRETS_KEY. Once saved they are never shown again, not even to management. A Slack webhook's URL is itself the secret, so the URL is treated like one; - a description and an example payload, for the people and agents who will use it: what it is for, and what shape it expects.
A script names the connection and gives the payload, at most 64 KB. The script never names the URL, the method or the headers, so where data may go is management's decision, made once.
Delivery accepts only https, refuses loopback, private, link-local and metadata addresses (checked after the name resolves), follows no redirects and times out after 10 s. A failed delivery is retried with the outbox's backoff.
Agents are told what they can use
An agent writing a script must know which connections exist and how to call them. So:
- A new MCP tool,
list_connections(read-only), returns:- which of the three capabilities are on;
- for each connection: its name, kind, description and example payload, the host it posts to (not
the URL), and the exact call, such as
actions.post('slack-ops', { text: '…' }).
create_automation's description tells the agent to readlist_connectionsbefore writing a script that posts, emails or asks.- The automation form's contract, and Try it, list the same for a person.
- A skill about automations, where an instance has one, says the same.
Email: to members of the instance only
actions.email(principal, subject, body) takes a member's address, me, or group:slug (each
member of the group). Anyone else is refused, and so is an agent, which has no mailbox.
- What it sends: a plain-text body of at most 32 KB. It goes from the instance's sender with the automation's owner as Reply-To. A footer names the automation and its owner, and links to its page.
- Who gets it: as with
notify, when the run has a subject document, only the members who can read that document. - Limits: an automation sends at most 100 emails a day across all runs, on top of the 50 actions a run may propose.
- Mail settings: the core sends mail through a mail server setting of its own, which the console sets for hosted instances. Without one, email cannot be switched on.
The model: on the instance's own key, answering into a page
actions.ask(prompt, { documents, into }). While the script runs, the host checks that the owner can
read every document listed (at most 20), and that into is an address in output.
After commit, a worker sends the prompt and those documents to the model. It writes the answer as a
page at into, owned by the automation's owner, or as a new version of the page already there. The
page says which automation wrote it, from which documents.
- The key is the instance's own: settings for the provider (Gemini first), the model and its key. Hosted instances get them from the console, separate from Scribe's.
- Without a key, the model cannot be switched on.
- A monthly budget, set by management, refuses asks once it is spent. Spending is counted with the instance's usage.
The answer never acts on its own. It is a page in output, private until someone shares it.
Anything further happens the ordinary way: a second automation, triggered by document.created on
that path, can post it to Slack. A model reading documents can be steered by what they say, so its
output becomes content, never share, unshare or tags.
Recorded like every action
A run records post, email and ask as queued when it commits. Each becomes delivered, or failed
with the reason, when the outbox has carried it out. The runs table, MCP and Try it show this.
Consequences
- CoDNA holds secrets.
CODNA_SECRETS_KEYmust be set to create a connection, and kept: without it the stored values cannot be read, and they have to be entered again. Backups hold them only encrypted. - The core makes outbound requests to the destinations management allows, so hosted instances need egress for them. The SSRF checks above are the guard inside the core.
- CoDNA calls a model, on an instance that switches it on. It still never calls one by itself.
Ingestion, distillation and search stay model-free, which is what 0010 protects. The prompt is the
script's; CoDNA adds only a short frame naming the documents. Hosted terms must cover sending
documents to the provider before any hosted instance switches
modelon. - A run is still reproducible from its record up to its proposals. Deliveries and answers are not, and are recorded instead.
- Dry runs stay safe. Nothing is posted, emailed or asked. Try it shows "would post to slack-ops" with the payload.
Alternatives rejected
fetch,sendEmailandaskModelas functions in the script: they break time, dry runs and secrets, as above.- Connections that each person creates: where an instance's data may go is decided once, by management.
- Email to any address: an automation would become a way to send company knowledge, or spam, anywhere.
- Model calls through Scribe, on its key: the core would depend on the console's services. A self-hosted core with its own key works on its own.
- A model answer that proposes actions: the documents it reads can steer it.
- A new trigger for answers: the answer page's
document.createdalready is one.
Still open
- Whether a connection can be limited to some groups.
- Data coming in: an incoming-webhook trigger, and GET connections read before a run. Not part of this; when built, probably a fourth switch.
- The default model and budget, and who may switch
modelon: set aside with step 3 on 2026-10-25.
Building it
Three steps, each shippable alone:
- The switches, connections and
actions.post: core storage and delivery, the manage API andcodnacommand,list_connections, run delivery status, and the console's screens. actions.email, with mail settings.actions.ask, with the model settings, the worker, the budget and its usage. Frozen on 2026-10-25 and not built.