# Penut Agent Manual

Version: 2026-05-30T02:08:31.419Z

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 with the setup code from the user's message:

```bash
penut auth login --code <code>
penut auth status
```

## 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**  | Global meta                                 | `penut <verb>`                                           | `penut version`, `penut update`                                   |
| **2**  | CLI built-in (local/meta, not a server op)  | `penut <area> <verb>`                                    | `penut auth login`, `penut registry list`, `penut session switch` |
| **3**  | Registry op — foundation / native / support | `penut <service> <resource> <verb>`                      | `penut billing account status`                                    |
| **4**  | Registry op — integration / provider        | `penut integrations\|providers <name> <resource> <verb>` | `penut integrations x posts create --connection <uuid>`           |

### Determinism rules

- **1 token = meta**, always (a registry op is never 1 token).
- **2 tokens = built-in**, from a fixed reserved list of areas.
- **3 tokens = foundation op** — the first token is a non-namespaced service and is never an integration/provider name.
- **4 tokens = integration/provider op** — the first token is literally `integrations` or `providers`; the second is the connection/provider name.
- **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 (`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`) is not valid. Use `penut integrations 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 and will never route to a registry op: `help`, `version`, `update`, `config`, `auth`, `session`, `registry`, `recipes`, `changelog`.

### Built-in commands

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

**Tier 2 (platform built-ins):**

- `penut auth login --code <CODE>`, `penut auth status`, `penut auth logout`
- `penut session switch <project>`
- `penut registry list [--category CATEGORY] [--name SERVICE]`, `penut registry op <KEY>`, `penut registry openapi`
- `penut recipes list|show|search`
- `penut changelog list [--since DATE] [--type TYPE]`

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

## Response Shapes

The raw HTTP API returns one of these shapes. When using the CLI, the output is always JSON and wraps the result in `{ operation, idempotencyKey, result }`.

### Success

```json
{ "ok": true, ...data }
```

### 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": "integrations.x.posts.create",
    "status": "pending"
  }
}
```

### Error

Generic errors return HTTP 4xx/5xx with:

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

Gateway hook errors return a different shape (nested `error` object, top-level `ok: false`). Check for `error.stage === "gateway_hook"` to identify them.

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

## 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 Batch

For related actions that should be reviewed together (e.g., cross-posting to X + LinkedIn):

```bash
# 1. Create empty draft
penut approvals batches create --title "Cross-channel launch"
# → { batch: { id: "ba_...", actions: [], status: "draft" } }

# 2. Append actions via --batch <uuid>
penut integrations x posts create --batch ba_... --connection <uuid> --text "Launch!"
# → { batchUuid: "ba_...", actionUuid: "act_...", position: 0 }

penut integrations linkedin posts create --batch ba_... --connection <uuid> --text "Launch!"
# → { batchUuid: "ba_...", actionUuid: "act_...", position: 1 }

# 3. Submit for review
penut approvals batches submit --id ba_...
# → { batch: { id: "ba_...", status: "pending" } }

# Rollback a draft (drop without submitting)
penut approvals batches drop --id ba_...
# → { ok: true, batchId: "ba_...", status: "dropped" }
```

Use `--batch` without a UUID value to create a new draft and add the first action in one step. Use `--batch <uuid>` to append to an existing draft.

### Action Statuses

| Status      | Meaning                                            |
| ----------- | -------------------------------------------------- |
| `pending`   | Awaiting user decision                             |
| `approved`  | User said yes, queued for execution                |
| `rejected`  | User said no — final                               |
| `executing` | Currently running                                  |
| `executed`  | Ran successfully                                   |
| `blocked`   | Approved but execution can't proceed (recoverable) |
| `failed`    | Hit an irrecoverable error — final                 |
| `expired`   | TTL hit before decision or resolution              |

### Request Statuses

| Status     | Meaning                              |
| ---------- | ------------------------------------ |
| `draft`    | Batch being built, not yet submitted |
| `pending`  | Submitted, awaiting action decisions |
| `resolved` | All actions have been decided        |
| `executed` | All approved actions executed        |
| `canceled` | Canceled by user                     |
| `expired`  | TTL hit before resolution            |

### Blocked State — Manual Retry

An approved action can hit a recoverable condition (connection revoked, out of credit, provider unavailable). It moves to `blocked` with a `blocked_reason`.

The user fixes the underlying issue, then retries:

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

Retry re-executes the same approved action without requiring re-approval. If the condition is fixed → `executed`. If still broken → stays `blocked`. No background retries. Retry is also allowed on `failed` actions.

### Failed State — No Retry

Actions hit `failed` for: stale resource reference (deleted UUID), op version skew, op-specific permanent failures (e.g., "tweet violates policy"), scope revoked after approval.

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

### Cancel

```bash
penut approvals requests cancel --id <uuid>
```

Sets the request status to `canceled` and all its actions to `rejected`.

## 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 objects by id, 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, and a sample row. `penut db schema dependents --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, email FROM users 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 users (email) VALUES ($1) RETURNING id" --params '["alice@example.com"]'
```

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

### Transactions

Mirror the approvals batch shape. 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 schema dependents` to find them.

### Quotas and limits

- 30s statement timeout per call.
- 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).
- For bulk import, use `penut db sql copy --table <name> --columns '["col1","col2"]'` with rows on stdin — never write `INSERT … VALUES` with thousands of rows.

### 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. Every file is referenced by UUID; filename is metadata for display only.

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
# Upload one or more files to Penut Storage (one object per file)
penut storage objects upload --in content=./q2-report.pdf --title "Q2 report"
penut storage objects upload --in content=./assets/        # folder → N objects

# Download a storage object to disk
penut storage objects download --object obj_4d1 --out content=./q2-report.pdf

# Replace an existing object's bytes
penut storage objects update --object obj_4d1 --in content=./q2-report-v2.pdf

penut storage objects read --id obj_...       # metadata + signed GET URL
penut storage objects list --kind "image"
penut storage objects remove --id obj_...     # approval-gated soft-delete; dependents scan
```

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

**`--source*`** sends inline UTF-8 text (`--source "str"`, `--source-file ./code.ts`, `--source-stdin`). For binary files, always use `--in` — `--source-file` reads as text and corrupts binary.

- Signed URLs are TTL'd and per-object. Large files never transit the API.
- Delete is soft-delete (`deletedAt`) with blob retained. Removing an object requires approval; the preview includes a dependents scan of project code.
- Project-scoped (like `db`). No reference-counting — delete safety uses approval gating + on-demand dependents scan.
- Names are never unique identifiers — the UUID is. Uploading `doc.pdf` twice creates two objects, both displaying "doc.pdf", with different UUIDs.

## Connection Discovery Rule

Before using an integration, always list connections:

```bash
penut integrations connections list --integration <name>
```

If empty, start the OAuth flow:

```bash
penut integrations connections create --integration <name>
# Returns { authorizationUrl, state } — surface the URL to the user
```

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

## Common Patterns

### 1. Post to X

```bash
penut integrations connections list --integration x
penut integrations x posts create --connection <uuid> --text "Hello world"
```

### 2. Cross-Post (X + LinkedIn in one batch)

```bash
penut approvals batches create --title "Cross-channel launch"
penut integrations x posts create --batch ba_... --connection <x-uuid> --text "Launch!"
penut integrations linkedin posts create --batch ba_... --connection <li-uuid> --text "Launch!"
penut approvals batches submit --id ba_...
```

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

```bash
penut code functions deploy --name "my-function" --source-file ./handler.ts
penut code functions grant-scope --function <uuid> --scope "integrations.x.posts.create"
penut events triggers create --name "new-order" --event "shopify:order:created" --target <function-uuid>
```

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

```bash
penut db sql execute --sql "CREATE TABLE users (id bigserial PRIMARY KEY, email text NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now())"
penut db sql execute --sql "INSERT INTO users (email) VALUES ($1) RETURNING id" --params '["alice@example.com"]'
penut db sql query   --sql "SELECT id, email FROM users ORDER BY created_at DESC LIMIT 50"
```

### 4b. Upload a File to Storage

```bash
penut storage objects upload --in content=./report.pdf --title "Report"
penut storage objects list --kind "file"
```

### 5. Send Email via Gmail

```bash
penut integrations connections list --integration gmail
penut integrations gmail messages send --connection <uuid> --to "alice@example.com" --subject "Hello" --body "..."
```

### 6. Inspect Logs After a Failure

```bash
penut logs entries list --trace <traceId> --since 1h
penut logs entries show <entry-id>
```

## 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                                                                 |
| `PHYSICAL_ADDRESS_REQUIRED` | gateway_hook | Marketing email needs brand address | Store the brand's physical address in the project DB and reference it from the send op |
| `CONTENT_POLICY_REJECTED`   | gateway_hook | Ad creative failed content scan     | Remove policy-risky language                                                           |
| 401                         | generic      | Session expired                     | Re-authenticate                                                                        |
| 403                         | generic      | Missing scope                       | Request scope grant                                                                    |
| 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). Source input: `--source "string"`, `--source-file ./path`, or `--source-stdin`.
- **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>`. `--source*` is for inline UTF-8 text only, never binary.

## 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 batch with unrelated actions.
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 catalog of categories + links                           |
| `penut registry list --category <cat>`  | Category resources                                                |
| `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.** If it failed with 403 or 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 schema dependents`.** Update consumers before backwards-incompatible changes.

## Conceptual Primer

- **Code functions**: Durable server-side TypeScript deployed via `penut code functions 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 code views deploy`. No server-side data fetching.
- **Code triggers**: Connect schedules (cron), platform events, webhooks, delays, and manual runs to functions.
- **Scopes**: Strict `<root>:<resource>:<verb>` grammar. `root` = integration name, service name, or `org`. Wildcards allowed in any segment (e.g., `integrations.x:*`). No shorthand — always three segments.

## Profile and Projects

```bash
penut auth status                      # User, current project, session info
penut session switch <uuid|name>       # Switch active project
penut identity projects list           # All accessible projects
penut identity projects read <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 and API version
penut changelog list --since <date>  # What changed
penut update                         # Update CLI and re-read this manual
```

When the user says "update penut", run `penut update` — it downloads the latest CLI. Then unconditionally re-fetch and re-read the served manual at `https://penut.ai/agent/SKILL.md`, regardless of version. The `skill` field in `penut version` output is the staleness anchor: compare it to the `Version:` line in the re-read manual to confirm the update took effect.

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