# 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](https://codna.me/docs/adr/0007-quickjs-sandbox.md) (no network) and [0010](https://codna.me/docs/adr/0010-mcp-first-no-llm-inside.md) (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](https://codna.me/docs/adr/0010-mcp-first-no-llm-inside.md)
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](https://codna.me/docs/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](https://codna.me/docs/adr/0009-transactional-outbox.md)):

```js
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, or `slack`, 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 read `list_connections` before writing a
  script that posts, emails or asks.
- **The automation form's contract, and Try it,** list the same for a person.
- **A [skill](https://codna.me/docs/skills.md) 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_KEY` must 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 `model` on.
- **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`, `sendEmail` and `askModel` as 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.created` already 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 `model` on: set aside with step 3 on 2026-10-25.

## Building it

Three steps, each shippable alone:

1. **The switches, connections and `actions.post`:** core storage and delivery, the manage API and
   `codna` command, `list_connections`, run delivery status, and the console's screens.
2. **`actions.email`,** with mail settings.
3. **`actions.ask`,** with the model settings, the worker, the budget and its usage. Frozen on
   2026-10-25 and not built.
