# Penut Agent Manual

Version: 2026-07-19T03:15:05.395Z

Penut is a growth platform. As an AI agent, you drive it on behalf of the user via the `penut` CLI. You propose actions; the user reviews and approves; the platform executes.

## Install and Authenticate

If `penut` isn't installed:

```bash
/bin/bash -c "$(curl -fsSL https://penut.ai/install)"
```

Authenticate interactively:

```bash
penut auth login
penut auth status
```

For a browserless or remote environment, use OAuth device login:

```bash
penut auth login --device
```

Device codes are single-use. After an approved code is exchanged for OAuth
access/refresh tokens, reusing that code returns `invalid_grant`.

## Identity, Authorization, and Selection

Penut separates identity, authorization, and selection.

| Concept       | Meaning                                                                                                                                       |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Identity      | Credentials identify a principal only. Human OAuth identifies a user. Machine OAuth or API keys identify an `api_key` principal in one org.   |
| Authorization | Principal scopes, also called grants, live in `PermissionGrants` and decide which actions/resources/projects the principal may use.           |
| Selection     | The active org and project for this call. Selection is per-call or local ambient state, never baked into OAuth tokens or API-key credentials. |

OAuth token scopes are only a coarse gate: `read`, `write`, and `offline_access`.
Real access comes from principal scopes/grants. Effective permission is the
intersection of token scope and grants.

The CLI sends OAuth bearer tokens plus per-call selection headers.

Selection precedence:

1. `.penut` file found by walking up from the current directory.
2. `PENUT_ORG` and `PENUT_PROJECT` environment variables.
3. Explicit `--org` and `--project` flags.
4. Reserved `$org` and `$project` fields in JSON operation input for one-call
   override.

Use `penut init --org <org-uuid-or-name> --project <project-uuid-or-name>` to
write a local `.penut` file. There is no persistent account-level active project
and no `penut session switch`.

## Command Grammar

The `penut` CLI uses a deterministic token-count grammar. Counting only command tokens (flags and values such as `URL`, `KEY`, `<uuid>` do not count):

| Tokens | Category                                      | Shape                                                 | Example                                                |
| ------ | --------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------ |
| **1**  | Meta                                          | `penut <verb>`                                        | `penut version`, `penut update`                        |
| **2**  | System registry op or built-in command family | `penut <area> <verb>`                                 | `penut billing status`, `penut feedback submit`        |
| **3**  | Core registry op                              | `penut <service> <resource> <verb>`                   | `penut functions deployments list`                     |
| **4**  | Specific integration / provider registry op   | `penut integration\|provider <key> <resource> <verb>` | `penut integration x posts create --connection <uuid>` |

### Determinism rules

- **1 token = meta**, always (a server registry op is never 1 token).
- **2 tokens = system op or built-in command family.** The only system registry op with a 2-segment key is `feedback:submit`. `billing`, `auth`, `init`, `registry`, `recipes`, `changelog`, and `skill` are CLI-hardcoded command families.
- **3 tokens = core service op** — the first token is a non-namespaced service such as `identity`, `db`, `storage`, `functions`, `views`, `messaging`, `events`, `app`, `approvals`, `logs`, `integration`, `providers`, or `services`.
- **4 tokens = specific integration/provider op** — the first token is singular `integration` or `provider`; the second is the integration/provider key.
- **Multi-word resources are a single token with underscores** and never split (e.g. `identity api_keys create`, `db sql_transaction begin`). This protects the bijection.
- **Colon form (`integration:x:posts:create`) is an op-key identifier** used in the registry, scopes, and docs. It is NOT a CLI invocation style.
- **Integration/provider ops always use 4 tokens.** The bare 3-token shorthand (`penut x posts create`) and plural tier-4 form (`penut integrations x posts create`) are not valid. Use singular `penut integration x posts create`.
- **`--connection <uuid>` is required** for all tier-4 integration ops.
- **Action verbs are exact and un-normalized.** Use the exact action segment from the op key (discover via `penut registry op <key>`). The CLI does not rewrite or alias verbs — `db sql query` stays `query`, `identity orgs show` stays `show`. Common actions include `create`, `read`, `list`, `update`, `archive`, `query`, `show`, `status`, `write`, `execute`, `commit`, `drop`, `deploy`, `approve`, `reject`, `retry`, `submit`, `publish`, `remove`, `refresh`, and many more. When in doubt, read the op key.

### Reserved built-in roots

These token-1 / token-2 roots are reserved command families: `help`, `version`, `update`, `config`, `auth`, `init`, `registry`, `recipes`, `changelog`, `billing`, `skill`. `feedback` is a system registry op with a two-segment key.

### Built-in commands

**Tier 1 (meta):** `penut version`, `penut update`

**Tier 2 (platform built-ins and system ops):**

- `penut auth login`, `penut auth login --open`, `penut auth login --device`, `penut auth status`, `penut auth logout`
- `penut init --org <org> --project <project>`
- `penut registry list [--path PATH] [--search QUERY]`, `penut registry op <key>`, `penut registry openapi`
- `penut recipes list|show|search`
- `penut changelog list [--since DATE] [--type TYPE]`
- `penut skill read`
- `penut billing status`, `penut billing connect`, `penut billing top-up --usd 25`, `penut billing settings --monthly-cap 100`
- `penut feedback submit`

Everything else is a registry op (tier 3 or 4).

## Response Shapes

The HTTP API returns one of the shapes below directly. The CLI wraps the response in `{ operation, idempotencyKey, result }` where `result` is the raw body.

### Success

Most ops return domain-specific payloads directly (e.g., `{ entries: [...] }`). A subset returns `{ "ok": true, ...data }`. Check the operation schema for the expected shape.

### Approval Required

Some operations require human review. The API returns HTTP 202:

```json
{
  "ok": false,
  "status": 202,
  "error": {
    "stage": "gateway_hook",
    "code": "APPROVAL_REQUIRED",
    "message": "Gateway policy requires human approval before this operation runs.",
    "suggestion": "Approve the generated request from Console or an approval deep link."
  },
  "approval": {
    "id": "<uuid>",
    "kind": "social-publish",
    "status": "pending"
  }
}
```

### Error

Generic errors return HTTP 4xx/5xx with:

```json
{
  "status": "error",
  "code": "snake_case_code",
  "message": "Human-readable explanation",
  "error": "Human-readable explanation",
  "suggestion": "Action the user should take",
  "retryable": false,
  "details": {}
}
```

The `error` field duplicates `message` for client convenience. If `details.remedy` is set (e.g. `{ cli: "..." }`), a `remedy` field is also present.

Gateway hook errors return a different shape (nested `error` object, top-level `ok: false`). Check for `error.stage === "gateway_hook"` to identify them. Some ops also return approval shapes that differ from the gateway shape — inspect the `approval.id` field to detect approval responses.

### Agent Loop

1. Call op.
2. `result.ok === true` → done.
3. `result.ok === false` and `result.status === 202` and `result.error.code === "APPROVAL_REQUIRED"` → surface `result.approval.id` to the user, exit.
4. `result.status === "error"` and `result.retryable === true` → follow `suggestion`, retry once.
5. Otherwise → surface error with `suggestion` to the user.

## Capability Discovery First

Before you answer a question about the project's data, or conclude that something
"can't be done," **search the registry**. The platform's surface is broad and not
obvious from any single tool — analytics, storage, integrations, providers, functions,
and more are all registry ops.

```bash
penut registry list --search "<keywords>"
```

Search is **semantic**: pass several synonyms and related terms in one query
(e.g. `"users audience analytics signups"`), not a single word. Results return
top-ranked `operations` and `events` arrays. Operation search rows identify a
resource-level key and available verbs; drill in with the exact operation key,
for example `penut registry op logs:entries:list`, for the authoritative params,
sample response, and examples. If the target capability is not in the top
results, refine the search keywords instead of guessing.

Examples use bracketed placeholders such as `<uuid>`, `<op-key>`, and `<connection-id>`.
Replace them with real values. Do not include the angle brackets in the final
command.

Many list operations use generic keyset pagination: `--limit <n>` controls page
size and `--cursor <opaque-cursor>` fetches the next page when the response
includes `nextCursor`. Cursors are opaque; pass them back exactly as returned.

Do not default to the first tool that comes to mind. "How many users do we have?" is
not automatically a project-DB question — search first; the answer may live in an
analytics integration (e.g. PostHog), a provider, or storage. Treat a capability as
missing only after a real search has come up empty (see [Boundary Principle](#boundary-principle)).

## Browser Tasks

Browser-executable work is a first-class Penut execution path. The user does not
need to explicitly say "browser task" or "use Operator". Infer the execution
path from the requested outcome and the available registry capabilities.

Prefer a normal Penut API or integration operation when all of these are true:

- The operation exists in the registry.
- The user or project has the required connection and permissions.
- The platform API supports the requested action.
- The action can safely run server-side.

If the requested outcome is something a person would normally do in a web
browser, and no safer direct API or integration operation is available, use the
specific operation that declares an Operator browser playbook. If no operation
declares a browser playbook for that workflow, explain that the browser
execution path is not available yet instead of inventing a generic browser task.

Do not conclude the task is impossible just because no direct integration
operation exists. Typical browser-backed examples are LinkedIn DMs, Instagram
comments or DMs, Telegram Web actions, website forms, logged-in dashboards,
admin panels, checkout flows, public web forms, and other workflows a human
would normally complete in a browser.

Operator browser execution does not run browser automation on the server. After
approval, the server sends the approved action to the extension in the browser
profile used to approve it, which executes it in a dedicated local browser tab.

## Approval Lifecycle

Some operations trigger a gateway "approval required" response. The server creates an `ApprovalRequest` (with one `ApprovalAction` inside) and returns the approval ID. Approvals are reviewed in the Console at the approvals inbox.

### Single Action (default)

When an op triggers approval, the agent surfaces the approval ID to the user. The user visits the Console approvals page, reviews, and approves/rejects. The action executes after approval.

### Multi-Action Requests

When multiple pending actions should be reviewed together, create one approval
request and attach the actions to it. Each action uses the same operation key and
params shape as the real operation being reviewed.

```bash
# Create a request with its actions in one call
penut approvals requests create --title "Cross-channel launch" \
  --actions '[{"opKey":"integration:x:posts:create","params":{"connection":"<x-uuid>","text":"Launch!"}}]'
# → { request: { id: "<uuid>", actions: [ ... ] } }

# Or append actions to an existing request
penut approvals actions append --approvalRequestId <uuid> \
  --action '{"opKey":"integration:linkedin:posts:create","params":{"connection":"<li-uuid>","text":"Launch!"}}'
```

Surface the request id to the user; actions execute after they are approved.

### Action Statuses

| Status     | Meaning                                               |
| ---------- | ----------------------------------------------------- |
| `pending`  | Awaiting user decision                                |
| `approved` | User said yes; execution result is tracked separately |
| `rejected` | User said no — final                                  |
| `expired`  | TTL hit before decision or resolution                 |

### Request Statuses

| Status     | Meaning                              |
| ---------- | ------------------------------------ |
| `pending`  | Submitted, awaiting action decisions |
| `resolved` | All actions have been decided        |
| `canceled` | Canceled by user                     |
| `expired`  | TTL hit before resolution            |

### Execution Errors

If execution fails after approval, the approval detail contains the execution
error. If an action is still pending, update params before approval with
`approvals actions update`.

### Update Params

```bash
penut approvals actions update --id <uuid> --params '{"text": "New draft"}'
```

Allowed when the action's request is in `draft` status or the action itself is `pending`.

### Reject All Pending Actions

```bash
penut approvals actions reject --id <uuid>
```

Sets all pending actions on the request to `rejected`.

## Permissions

Every principal — you (acting as the user's member), OAuth clients, API keys, and machine principals —
has an explicit set of permission grants. **Access is deny-by-default: you can only
do what has been explicitly granted.** The org owner (the org creator) implicitly
has everything. New members start from a baseline "Member" permission set their
org assigns on invite, plus anything granted to them individually.

### Grant shape

A grant is structured, not a bare string:

| Field      | Meaning                                                                  |
| ---------- | ------------------------------------------------------------------------ |
| `effect`   | `allow` or `deny`                                                        |
| `action`   | an op key or wildcard — `integration:x:posts:create`, `db:*`, `*`        |
| `projects` | `*` or specific project UUIDs the grant applies to                       |
| `on`       | `*` or specific resource UUIDs (tables, connections, files, functions …) |

Evaluation is flat and **deny always wins**: a call is allowed only if some `allow`
matches and no `deny` matches. A broad `allow` with a narrower `deny` carve-out is
normal and intended (e.g. allow `db:*` but deny `db:sql:*` on the `secrets` table).
Wildcards: `*` = everything, `db:*` = a subtree, `integration:*:posts:create`
matches one segment. Scope roots correspond to registry services — discover them
with `penut registry list`; don't rely on a memorized list.

### The surface is fully visible — access is annotated, not hidden

`penut registry list` and `penut registry op <key>` return **every** operation,
whether or not you can currently run it. Each op includes `required` (the action
needed) and `access` (the matching grants for you):

```json
{
  "required": "storage:files:read",
  "access": [
    { "effect": "allow", "action": "storage:*", "projects": "*", "on": "*" },
    {
      "effect": "deny",
      "action": "storage:files:read",
      "projects": "*",
      "on": ["<file-uuid>"],
      "addedBy": { "name": "Owner", "email": "owner@example.com" }
    }
  ]
}
```

An op you can't run is shown as **denied**, not hidden — so you can see it exists
and request it. **Never conclude a capability "doesn't exist" because you can't
run it** — check whether it's denied, and request access (this is distinct from
the [Boundary Principle](#boundary-principle), which is about capabilities that
genuinely aren't in the registry at all).

Instances are different from the catalog: `list`/`describe` ops return only the
resources you're permitted to see. Denied tables/files/connections aren't listed,
and their data is never returned.

### When you're denied (403)

A blocked op returns `403` with `code: "permission_required"` and a `remedy`. There
are two cases — the `remedy` tells you which:

- **Ungranted** (no matching allow). Request the grant; it routes to the owner for
  approval:

  ```bash
  penut identity principal_scopes request --principalKind member --id <your-member-id> \
    --grants '[{"effect":"allow","action":"<op-key>","projects":"*","on":"*"}]'
  ```

  Surface the returned approval to the user. Ask for everything you need in one
  request — group related actions together rather than across separate requests.

- **Explicitly denied** (a `deny` matches). Requesting an allow will **not** help —
  deny wins. The deny must be **removed by its author or the org owner**. The
  `remedy` names the author so you can tell the user who to contact; the request you
  send targets that author and the owner.

You can always inspect **your own** grants and the author of a deny blocking you
without needing any permission. Reading another member's grants needs
`identity:principal_scopes:read`. **Never loop on a 403** — request once, then surface
to the user.

### Functions, events, and delegation

A deployed function carries its own granted scopes and runs with that authority.
Consequences for you:

- To attach a function to an event listener — or to replay a delivery — you need
  permission to **invoke that function** (`functions:items:invoke` on it), in
  addition to `events:listeners:create`.
- Subscribing to an integration's event also requires the matching **event-key**
  scope — the exact key from `penut events available list` (e.g.
  `integration:gmail:messages_received`), which is that integration's permission.
  You can still _discover_ such events without it.
- Grant a function the scopes its code needs with
  `penut functions function_scopes grant` (see Conceptual Primer).

## Database and Storage

Every project has two independent persistence primitives: a real Postgres schema (DB) and an S3-backed object store (Storage). They are separate ops — DB rows reference storage files/items by path or objectId, not the other way around.

### DB model

- Each project gets its own Postgres schema (`project_<id>`) inside a shared `penut_projects` database. The schema is isolated by role — agents only see their own project's tables.
- The agent writes plain SQL. There is no DSL, no ORM layer, and no migration files. DDL statements _are_ the migration.
- Schema introspection is free: `penut db schema describe` (optionally `--table <name>`) returns columns, types, nullability, defaults, row counts, CHECK constraints, and foreign keys. `penut db dependents list --table <name> [--column <name>]` lists code functions and views that reference that table.

### Read vs write split

Two ops, enforced server-side. Use the right one.

```bash
# Reads — server wraps the statement in BEGIN READ ONLY ... COMMIT.
penut db sql query --sql "SELECT id, status FROM <table> WHERE created_at > $1 ORDER BY id DESC" --params '["2026-01-01T00:00:00Z"]'

# Writes + DDL — one statement per call. For multiple statements, use a transaction.
penut db sql execute --sql "INSERT INTO <table> (name) VALUES ($1) RETURNING id" --params '["<value>"]'
```

- Bind parameters are Postgres-native `$1`/`$2`/… passed as a JSON array via `--params '[...]'` or `--in params=./params.json`.
- Multi-line SQL: use `--in sql=./path.sql` or `--in sql=-` for stdin.
- `RETURNING` clauses populate `rows` on the `db sql execute` response.

### Transactions

Queue statements into one transaction, then commit them together. The server holds the transaction with an inactivity TTL (auto-rollback if abandoned).

```bash
penut db sql_transaction create
# → { txId: "tx_...", expiresAt: "..." }

penut db sql_transaction execute --id tx_... --sql "INSERT INTO orders (...) VALUES ($1)" --params '[...]'
penut db sql_transaction execute --id tx_... --sql "UPDATE inventory SET qty = qty - 1 WHERE sku = $1" --params '["abc"]'

penut db sql_transaction commit --id tx_...   # or
penut db sql_transaction drop --id tx_...     # explicit rollback
```

Transaction-level approval evaluates the union of all enqueued statements' classifications (see matrix below).

### Approval matrix

Approval is driven by the SQL verb plus presence/absence of `WHERE`. When in doubt, the server requires approval.

| Approval required                                 | Runs without approval                                     |
| ------------------------------------------------- | --------------------------------------------------------- |
| `DROP …`, `TRUNCATE`                              | `CREATE TABLE`, `CREATE INDEX`, `CREATE VIEW`             |
| `ALTER` that removes / renames / retypes a column | `ALTER TABLE … ADD COLUMN` (nullable, no default rewrite) |
| `SET NOT NULL` on an existing column              | `INSERT`                                                  |
| `DELETE` or `UPDATE` **without** `WHERE`          | `DELETE … WHERE`, `UPDATE … WHERE`                        |
| `ALTER TYPE … ADD VALUE` on an enum               |                                                           |

### DDL ordering rule

- **Forward-compatible** change (`ADD COLUMN` nullable, `CREATE INDEX`): `ALTER` first, then update consumers.
- **Backwards-incompatible** change (`DROP COLUMN`, rename, retype): update consumers (code functions, views) first, then `ALTER`. Use `db dependents list` to find them.

### Quotas and limits

- 30s statement timeout per direct call; queued transaction commits have a higher commit timeout for large atomic jobs.
- Naked `SELECT` without `LIMIT` gets a `LIMIT 1000` injected; the response sets `truncated: true` if it kicks in.
- Max 10,000 rows returned per call. For larger reads, use `db sql export --format csv|jsonl` (returns a signed URL to an artifact).
- Request bodies for `db:*` JSON calls are capped at 25 mb. For bulk import within that limit, use one parameterized multi-row `INSERT … VALUES (...), (...)` through `db sql execute`. Above that limit, use `db sql_transaction` and queue explicit atomic batches.

### Type marshalling over the wire

| Postgres type              | JSON representation                  |
| -------------------------- | ------------------------------------ |
| `timestamptz`, `timestamp` | ISO 8601 string                      |
| `uuid`                     | string                               |
| `bigint`, `numeric`        | string (precision-safe; never float) |
| `bytea`                    | base64 string                        |
| `jsonb`, `json`            | JSON value as-is                     |

Idempotency: pass `--idempotency-key` to `db sql execute` so an approval retry doesn't double-apply. Defensive `INSERT … ON CONFLICT` is encouraged on writes the agent might retry.

### Best-practice schema defaults

The `db schema describe` hints and the DDL validator nudge toward these — follow them unless the scenario disagrees.

- `id bigserial PRIMARY KEY` by default. Add `uuid uuid UNIQUE DEFAULT gen_random_uuid()` only when external references matter.
- `created_at timestamptz NOT NULL DEFAULT now()` on every table.
- Prefer `status text CHECK (status IN (...))` over Postgres `ENUM` (cheaper to evolve).
- Hard-delete by default. Add `deleted_at timestamptz` and filter `IS NULL` only when the scenario requires soft-delete.
- Index any column you query in `WHERE` / `ORDER BY` hot paths. Index every foreign-key column.
- Use `jsonb` for shape-varying nested data; promote frequently-queried fields to real columns.
- Always declare a `PRIMARY KEY` or `UNIQUE` on `CREATE TABLE` — the validator rejects tables without one.

### Storage

Separate primitive from DB. Use it for files, images, exports — anything that shouldn't live as a row. Storage is path-addressed for navigation and also returns stable object UUIDs. Filenames are display metadata; use paths for storage commands and `objectId` for file references passed into other operations.

Storage has two registry surfaces:

- `storage files ...` for file-level operations at a specific path.
- `storage items ...` for file/folder tree operations such as list, move, copy, remove, and export.

Files are transferred via the CLI's `--in`/`--out` flags — one mechanism for every op, every destination. The CLI handles presigned URLs, ETag verification, and confirmation internally. Agents never see upload plumbing.

```bash
# Mechanism example only; run `penut registry op <key>` for the operation's exact params.
penut storage files create --path '["reports","q2-report.pdf"]' --in content=./q2-report.pdf
penut storage items export --path '["reports","q2-report.pdf"]' --out content=./q2-report.pdf
```

**`--in`** sends file(s): `--in <field>=<path>` where `<path>` can be a single file, directory (recurses, preserves structure), or repeated for arrays. **`--out`** receives file(s): `--out <field>=<path>` — scalar writes to a file, array writes to a directory. The op's schema declares which fields are file fields (`x-penut-file` marker) — run `penut registry op <key>` to discover them.

For non-file params, `--in <field>=<path>` reads UTF-8 text into that field, and `--in <field>=-` reads stdin. String schema fields stay raw text; structured fields such as `params` or `rows` parse JSON. For binary files, use `--in` only on schema-declared file fields.

Documents can be parsed and searched after upload:

Use `storage files content` to trigger/read extraction for document-like files. Use `storage files search` to verify facts from parsed content before answering document questions; do not answer from a filename or upload result alone.

Provider-generated media is saved into storage under `penut/` and is managed with the same storage commands as any other project file. Accessing existing storage is free; do not regenerate media just to download or reuse it.

- Signed URLs are TTL'd and per-object. Large files never transit the API.
- Delete is soft-delete (`deletedAt`) with blob retained. Removing an item requires approval; the preview includes a dependents scan.
- `penut/` is visible in `storage items list/search/read/link/export`. Generic create/update under service-owned `penut/` subtrees is blocked; use the owning typed operation for functions, views, templates, providers, and integrations.
- Project-scoped (like `db`). No reference-counting — delete safety uses approval gating + on-demand dependents scan.
- Names are not global identifiers. Use returned paths or UUIDs from the server; never construct UUIDs.

## Connection Discovery Rule

Before using an integration, always list connections:

Run the registry-discovered connection list operation for the integration name. If empty, run the registry-discovered connection create operation and surface the returned authorization URL to the user.

Always pass `--connection <uuid>` on every integration op. The CLI enforces this.

## Common Patterns

### 1. Post to X

List connections for `x`, then run `penut registry op integration:x:posts:create` and use that definition as the source of truth for the post command.

For X replies and quote posts, use `integration:x:posts:create` with the
registry-documented `inReplyTo` or `quoteTweetId` fields. For multi-post X
threads, use `integration:x:threads:create`; do not simulate a thread by guessing
reply ids unless the thread operation is unavailable in the registry.

### 2. Cross-Post (X + LinkedIn reviewed together)

Create one approval request with one prepared action per channel. Discover each operation's action params with `penut registry op <key>`, then surface the request id to the user; each action executes after it is approved.

### 3. Deploy a Function and Configure a Trigger

Discover the function deploy, function scope grant, and event trigger/listener operations through the registry before invoking them. For source code, use the CLI's text source mechanism; for binary files, use `--in`.

### 4. Create a Table and Read it Back

```bash
penut db sql execute --sql "CREATE TABLE <table> (id bigserial PRIMARY KEY, name text NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now())"
penut db sql execute --sql "INSERT INTO <table> (name) VALUES ($1) RETURNING id" --params '["<value>"]'
penut db sql query   --sql "SELECT id, name FROM <table> ORDER BY created_at DESC LIMIT 50"
```

### 4b. Upload a File to Storage

Run `penut registry op storage:files:create` to confirm the upload field, then use `--in <field>=<path>`. List or read the resulting storage path/object id with the registry-discovered storage operation.

### 5. Send Email via Gmail

List Gmail connections first, then run `penut registry op integration:gmail:messages:send` and follow the current schema.

### 5b. Attach Integration Event Sources

Use Events for triggers. Do not invent integration-specific event commands.
Discover available dynamic sources, then create a listener with the returned connection UUID as the qualifier and provisioning connection. Use `penut registry op <key>` for the exact listener payload shape.

Gmail and Outlook inbound message events can include attachment storage object
UUIDs and paths in `attachments[]`; use storage read/link operations with those
returned paths or UUIDs instead of raw provider attachment IDs.

### 6. Inspect Logs After a Failure

Use the registry-discovered log list/read operations with the trace id returned by the failing operation.

## Common Errors

Error codes come from two sources: the generic `res.error()` helper and gateway hooks. Check `error.stage` to distinguish them (`"gateway_hook"` vs generic).

| Code                | Stage        | Meaning                                   | Action                                                                                                                                                                                                    |
| ------------------- | ------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APPROVAL_REQUIRED` | gateway_hook | Op requires human approval                | Surface the approval ID to user                                                                                                                                                                           |
| `no_connection`     | generic      | Integration not connected                 | Run `penut integrations connections create --integration <name>`                                                                                                                                          |
| `PROJECT_NOT_FOUND` | gateway_hook | Project resolution failed                 | Select a valid project                                                                                                                                                                                    |
| 401                 | generic      | Session expired                           | Re-authenticate                                                                                                                                                                                           |
| 403                 | generic      | Permission denied (`permission_required`) | Follow the `remedy`: if ungranted, `penut identity principal_scopes request`; if explicitly denied, ask the deny's author/owner to remove it. Request once — don't loop. See [Permissions](#permissions). |
| 404                 | generic      | Resource not found                        | Refresh and retry with current UUID                                                                                                                                                                       |
| 409                 | generic      | Conflict (stale state)                    | Refresh current state                                                                                                                                                                                     |
| 429                 | generic      | Rate-limited                              | Wait for reset, then retry                                                                                                                                                                                |

## Conventions

- **Output**: Always JSON. TTY detection is automatic — piping to a file or another command produces JSON. `--json` flag is NOT supported.
- **UUIDs** are canonical stable references. Never construct them — only use UUIDs returned by the server.
- **Names** are user-facing labels only. Not unique identifiers for API operations.
- **Destructive operations** use `archive`, `remove`, `revoke` verbs (soft delete). No hard deletes in v1.
- **Idempotency**: CLI auto-injects `Idempotency-Key` header on POST requests. Manual override with `--idempotency-key`.
- **Dry-run**: `--dry-run` to preview an operation without executing. Server returns `{ ok: true, dryRun: true, wouldExecute: {...} }`.
- **Parameters**: Use loose `--key value` flags for simple values, `--param key=value` for structured values (JSON strings are parsed), and `--in field=path|-` for file/stdin-backed values.
- **File transfer**: `--in field=path` for binary uploads (file, directory, repeat for arrays), `--out field=path` for downloads. The op schema declares file fields — discover via `registry op <key>`. For non-file params, `--in` reads UTF-8 text or JSON from a file/stdin.

## Best Practices

1. **Verify after important actions**: Read the resource back, or check `penut approvals requests read <id>`.
2. **Surface approval IDs immediately**: Don't queue silently. The user needs to act before the 7-day expiry.
3. **Don't loop on errors**: A failed scope or connection won't succeed on the second try. Surface to the user.
4. **Don't rely on approval being triggered automatically**: Approval is not guaranteed for every integration op. It depends on gateway hook conditions (bulk thresholds, content policy, etc.).
5. **Time-sensitive actions**: Surface the approval ID right away. Don't group unrelated actions into one request.
6. **Ask the user when uncertain**: If an op's params are unclear, run `penut registry op <key>` for the schema.
7. **Check before spending**: Provider ops and integration mutations cost org credits. Surface cost implications.

## Discovery

| Command                                 | What it returns                                                   |
| --------------------------------------- | ----------------------------------------------------------------- |
| `penut registry list`                   | Top-level command namespaces                                      |
| `penut registry list --path <path>`     | Child commands under a namespace                                  |
| `penut registry op <key>`               | Full operation schema (params, response, scopes, command, errors) |
| `penut recipes list`                    | All curated workflow recipes                                      |
| `penut recipes show <slug>`             | One recipe with description, requirements, and command sequence   |
| `penut recipes search <query>`          | Full-text recipe search                                           |
| `penut changelog list [--since <date>]` | Product updates since last visit                                  |

## Boundary Principle

The registry and CLI define the limit of what the platform can do. If a capability is not exposed as a registry operation, the platform cannot perform it through you. Do not invent Console steps or speculate about web UI workflows — the Console lists only what it can do (approvals, OAuth connections, logs, billing). For anything outside the registry, send feedback.

## What NOT To Do

- **Do not construct UUIDs.** Only use UUIDs returned by the server.
- **Do not inline API keys or session tokens** in code that gets committed.
- **Do not poll more than once per 2 seconds** on approval status.
- **Do not loop on the same error.** On 403, request access once (never re-call the same denied op in a loop), then surface to the user; on 404, surface to the user.
- **Do not use stale registry knowledge.** If a command fails, refetch the op schema before retrying.
- **Do not store secrets in DB or Storage.** Both are visible in the Console. Use integration connections for OAuth tokens; never paste API keys into a row or upload them as a file.
- **Do not run multi-statement SQL in a single `db sql execute` call.** One statement per call; use `db sql_transaction` for grouped writes.
- **Do not destructively `ALTER` or `DROP` without first checking `db dependents list`.** Update consumers before backwards-incompatible changes.

## Conceptual Primer

- **Code functions**: Durable server-side TypeScript deployed via `penut functions deployments deploy`. They run in response to triggers, call integration/provider ops, and can create approval requests.
- **Views**: Console-rendered React `page.tsx` files deployed via `penut views deployments deploy`. No server-side data fetching.
- **Code triggers**: Connect schedules (cron), platform events, webhooks, delays, and manual runs to functions.
- **Scopes / grants**: A grant's `action` is a variable-arity key matching operation keys: `feedback:submit` (2 segments), `storage:files:read` (3 segments), `integration:x:posts:create` (4 segments). Wildcards: `*` everything, `db:*` a subtree, `integration:*:posts:create` one segment. Scope roots are derived from registry services (discover via `penut registry list`) — not a fixed list. Grants are structured (`effect`/`action`/`projects`/`on`) and deny-by-default; see [Permissions](#permissions) for evaluation, the visible-but-denied surface, and how to request access.
- **Permission sets**: Reusable bundles of grants assigned to members (e.g. the org's default "Member" set, chosen at invite time). A member's effective access is their own grants plus any inherited sets, evaluated flat with deny-wins.

## Profile and Projects

```bash
penut auth status                      # User and resolved selection info
penut init --org <uuid|name> --project <uuid|name>
penut identity projects list           # All accessible projects
penut identity projects show --id <uuid>    # Project details
penut identity projects create --name "..."   # New project
```

## Recipe and Changelog Reference

- **Recipes** are curated workflows authored by the Penut team. Use them to accomplish common outcomes: welcome sequences, cart abandonment flows, cross-channel posts, weekly reports.
- **Changelog** entries document product updates, breaking changes, and migrations.

## Staying Current

```bash
penut version                        # Current CLI/API/SKILL staleness anchors
penut changelog list --since <date>  # What changed
penut update                         # Update CLI bundle
penut skill read                     # Print this manual and mark it current
```

Every server response may include an `update` object in CLI JSON output:

```json
{
  "update": {
    "cli": { "yours": "...", "current": "...", "action": "Run: penut update" },
    "skill": {
      "yours": "...",
      "current": "...",
      "action": "Run: penut skill read"
    }
  }
}
```

When `update.cli` appears, run `penut update`. When `update.skill` appears, run `penut skill read`; it prints the served manual to stdout and records the current skill version only after the read succeeds.

When a registry operation definition changes, the next use of that changed operation returns its definition with `firstUseAfterUpdate: true` instead of executing. Review the definition, then re-run the same command to execute it. Explicit discovery with `penut registry op <key>` is always fresh and refreshes the local operation cache.

When `penut update` cannot execute natively (no shell), fall back to printing the install one-liner:

```bash
/bin/bash -c "$(curl -fsSL https://penut.ai/install)"
```

## Feedback

If Penut behaves unexpectedly:

```bash
penut feedback submit --param title="..." --param message="..."
```

Include the exact command and response that failed.

When you (the agent) are blocked by something the platform should handle — an error that shouldn't reach users, missing functionality, confusing behavior — auto-send feedback with the appropriate `--kind` (`bug`, `feature`, `docs`, `integration`, `billing`, or `other` for confusion/unclear situations). The `--message` should narrate the relevant multi-turn conversation as a story: include the user's asks, your turns, the commands you ran, the results, and the reason you can't proceed.
