# Numu — AI Agents + Actions + Approvals — Full Walkthrough & API Reference

> Verified against the codebase on this branch. Where the design intent and the
> *wired* reality differ, this document states the **reality** and flags the gap.

---

## 0. The one-paragraph mental model (read this first)

Numu is **not** an execution engine. Numu is the **system of record + policy
resolver + approval ledger + idempotency store + audit log + human approval UI**.
**Make is the orchestrator and the executor.** The AI Agent (an LLM running inside
Make) analyses a startup and *proposes* an action. Make then asks Numu "what's the
policy for this agent+action?", and **Make decides** what to do next. Numu stores
every step durably and shows the human-facing Approvals screen. The actual startup
state change (the "Native Action") is performed by Make calling Numu's **existing**
admin/native-business-action paths — it is *not* auto-fired by the agent tables.

So the chain `Policy Engine → Approval Engine → Native Action` is a loop **Make
drives**, using Numu records at each step — it is not an automatic cascade inside Numu.

---

## 1. Architecture & ownership

```
AI Agent (LLM in Make)
   │  proposes an action for a startup
   ▼
agent_runs                ← Numu: durable run ledger (decision + execution, separated)
   │
   ▼
ActionPolicyService.resolve(agent, action, env, scope)
   │   override → designed (agent_action_policies) → default BLOCKED
   ▼
┌─ always_allow ─→ Make executes Native Action now
├─ needs_approval ─→ agent_approval_requests (PENDING) → human decides → Make executes
└─ blocked ───────→ nothing executes; Make records a blocked run
   │
   ▼
Native Action (Make → Numu admin/native API)  → startup.action_option_id changes
   │   fires StartupLabelOptionChanged → notifications / group move / meeting cancel
   ▼
Startup / Investor state change   + agent_runs.execution_status + audit log
```

| Concern | Where it lives | Notes |
|---|---|---|
| **Source of truth — agents** | `agents` table | typed `agent_type` (startup/investor); `registered_by` = seed / admin_ui / make_api |
| **Source of truth — assignments** | `agent_action_policies` (agent_key × action_key × environment) | versioned, full history (`effective_from`/`effective_to`), never hard-deleted |
| **Policy resolution** | `ActionPolicyService::resolve()` | override → designed → **default blocked** |
| **Runtime overrides** | `policy_runtime_overrides` | temporary scoped overrides (startup / environment / test_fixture) |
| **Runs** | `agent_runs` | idempotent by `idempotency_key`; decision fields freeze when analysis terminal; only the execution layer writes execution fields |
| **Approvals** | `agent_approval_requests` | created from a run when policy = needs_approval; one-shot decision |
| **Idempotency / lock for execution** | `execution_requests` | durable claim + `locked_until`; duplicate claim returns the original result |
| **Audit** | `AgentAuditLogger` (agent audit table) | every create/decision/policy-set/run/execution recorded |
| **Human approval UI** | `/admin/approvals` | list (status + date filters), detail + timeline, approve/reject/cancel |
| **Native action execution** | Make → existing Numu admin/native-business-action endpoints | NOT the agent tables; the agent tables are a control + audit plane |
| **Validation / orchestration / LLM** | **Make** | resolves policy, branches, re-validates expected-state, performs the native action, records results |

**Numu owns:** durable records, the policy source of truth + resolver, the approval
ledger + UI, idempotency guarantees, audit, runtime enable/pause.
**Make owns:** the LLM agent, orchestration (read policy → branch), executing the
native action, re-validating startup state before executing on approval.

---

## 2. Business workflow — the three scenarios

Subject for all three: **Pre-screen Agent** proposing **`request_more_info`** on startup #457.

### Scenario A — policy = `always_allow`
1. **Make sends:** `POST /api/v1/ai/agent-runs` (creates the run), then
   `PATCH /agent-runs/{run}/analysis-result` with `proposed_action=request_more_info`.
2. **Make asks policy:** `GET /api/v1/ai/action-policies/prescreen/request_more_info?environment=prod&startup_id=457`
   → `{ "policy": "always_allow", "source": "designed", "version": N }`.
3. **Make branches → execute now.** It claims an idempotency slot:
   `POST /api/v1/ai/execution-requests` (`status=processing`, lock set).
4. **Native action executes (Make → Numu):** Make performs `request_more_info` via the
   existing admin/native path → `startups.action_option_id` is set to the
   `request_more_info` option → `StartupLabelOptionChanged` fires →
   notification channels for that option send + any group move runs.
5. **Make records:** `POST /agent-runs/{run}/execution-results` (`execution_status=executed`)
   and `POST /execution-requests/{key}/result` (`status=succeeded`).
6. **Approval created?** ❌ No.
7. **Audit:** `agent.run_created`, `agent.run_analysis`, `agent.execution_claimed`,
   `agent.execution_result`, `agent.run_execution` (+ the normal label/notification logs).

### Scenario B — policy = `needs_approval`
1. Steps 1–2 as above; policy resolves to `needs_approval`.
2. **Approval created:** an `agent_approval_requests` row is created from the run
   (`status=PENDING`, captures agent/action/proposed group+status/current state/risk).
   → audit `agent.approval_created`. **⚠️ See §9 — the *endpoint* that creates this is
   not yet wired; today the record is created via the service/seed/test, not by Make.**
3. **Native action does NOT execute yet.** The startup is untouched.
4. **Admin workflow:** the approval appears in **/admin/approvals** (Pending). Admin opens
   it (first view stamps `viewed_at` → timeline), reviews risk/context, clicks
   **Approve** or **Reject** → `POST /admin/approvals/{id}/decide` (or API
   `POST /api/v1/ai/approvals/{id}/decide`).
5. **On approve:** status → `APPROVED`, `decided_by`/`decided_at`/comment stamped, audit
   `agent.approval_approve`. Make (polling `GET /approvals`) sees APPROVED, **re-validates
   the startup's expected group/status**, then executes the native action (as in Scenario A
   steps 3–5).
6. **On reject:** status → `REJECTED`, audit `agent.approval_reject`. **Nothing executes.**
   The startup stays as-is.
7. Decision is **one-shot** — a settled approval cannot be re-decided (throws/409).

### Scenario C — policy = `blocked`
1. Steps 1–2; policy resolves to `blocked` (either an explicit `blocked` designed policy,
   **or** the agent isn't assigned the action at all → **default blocked**).
2. **What's blocked:** the native action. It never runs; the startup never changes.
3. **Approval created?** ❌ No.
4. **What Make receives:** `{ "policy": "blocked", "source": "designed" | "default" }`.
5. **What's stored:** Make typically records `execution_status=blocked` on the run
   (`POST /agent-runs/{run}/execution-results`) → audit `agent.run_execution`
   (`execution=blocked`). No approval, no execution-request result.

---

## 3. All policy scenarios (matrix)

| | `always_allow` | `needs_approval` | `blocked` |
|---|---|---|---|
| **Behavior** | Make may execute immediately | Hold for a human decision | Never executes |
| **DB: agent_runs** | execution_status → executed | analysis recorded; execution after approve | execution_status → blocked |
| **DB: agent_approval_requests** | none | 1 row, PENDING → APPROVED/REJECTED | none |
| **DB: execution_requests** | 1 claim → succeeded | created only after approve | none (or none) |
| **Audit records** | run_created, run_analysis, execution_claimed, execution_result, run_execution | + approval_created, approval_approve/reject | run_created, run_analysis, run_execution(blocked) |
| **Policy API response** | `{policy:"always_allow",source:"designed",version}` | `{policy:"needs_approval",...}` | `{policy:"blocked",source:"designed"\|"default"}` |
| **Startup state** | changes | changes only on approve | unchanged |

> "always_allow" is **only a routing decision** — it does not bypass the safety
> validation Make performs before executing (re-checking expected group/status/version).

---

## 4. Startup agents — current configuration & journey

All 7 are seeded (`AgentsSeeder`, `agent_type=startup`, `registered_by=seed`). The seeder
assigns each action with **`needs_approval`** by default. (You can change any assignment to
`always_allow` / `blocked` from **AI Agents → Agent Details → Assigned Actions** or the
**Action Agent Policies** screen.) An action an agent is **not** assigned → **blocked** (default).

| Agent | Assigned actions | Default policy | Automatic? |
|---|---|---|---|
| **Intake Triage Agent** | move_to_review, reject | needs_approval | none (all need approval) |
| **Pre-screen Agent** | request_more_info, request_meeting, reject | needs_approval | none |
| **Founder Response Analysis Agent** | request_meeting, reject | needs_approval | none |
| **Screening Call Analysis Agent** | move_to_initial_dd, reject | needs_approval | none |
| **Initial Due Diligence Agent** | move_to_committee, reject | needs_approval | none |
| **Committee Analysis Agent** | move_to_prelist, reject | needs_approval | none |
| **Investment Memo Agent** | move_to_listing | needs_approval | none |

> Note: a few `reject` assignments may currently read `blocked` from manual testing in
> the UI — the *seeded* default is `needs_approval`. There are **no `always_allow`** and
> **no automatic** actions out of the box; every action is gated until you change it.

**Designed startup journey (each arrow is human-approved by default):**
```
new startup
  → Intake Triage: move_to_review        (approve) → in Review
  → Pre-screen: request_more_info / request_meeting / reject
  → Founder Response: request_meeting / reject
  → Screening Call: move_to_initial_dd   (approve) → Initial DD
  → Initial DD: move_to_committee         (approve) → Committee
  → Committee Analysis: move_to_prelist   (approve) → Pre-list
  → Investment Memo: move_to_listing      (approve) → Listed
  (reject available at most stages → rejection action + its notifications)
```

---

## 5. Investor agents

- **What exists:** the schema + UI **fully support** investor agents (`agent_type=investor`,
  the 👥 Investors tab on AI Agents, type selector on register, the investor `action`
  label + its options, investor action policies).
- **What's missing:** **no investor agents are seeded** and **no investor action policies
  exist** yet — by design (the investor pipeline isn't finalized).
- **How they'll work:** identical mechanics — create an investor agent from the UI (or
  `POST /api/v1/ai/agents`), assign investor action options, set each policy. Resolution,
  approvals, audit and execution behave exactly as for startups.
- **Expected investor actions:** to be defined as investor `action` label options (the
  catalog already supports them); none are canonical yet.

---

## 6. API reference

**Base:** `/api/v1/ai` · **Auth (this whole block):** `McpAuth:full` — an OAuth 2.1
bearer with ability `numu:full`, **or** a legacy Sanctum `naat_…` token with full ability.
`actorId` = the token's user (stamped on audit). Throttle 600/min. JSON in/out.

### Agents (registry + runtime)
| Method | Path | Payload / notes |
|---|---|---|
| `GET` | `/agents` | list |
| `POST`| `/agents` | `{agent_key, name, description?, agent_version?, core_skill_version?, stage_skill_version?, environment?, is_active?}` → registered_by=`make_api`. *(No `agent_type` field — API registrations default to `startup`; see §9.)* |
| `GET` | `/agents/{id}` | show |
| `GET` | `/agents/runtime-status` | global + per-agent effective status |
| `GET` | `/agents/{agentKey}/runtime-status` | one agent's effective status |
| `POST`| `/agents/{agentKey}/pause` | `{reason?, mode?: pause_new_only\|cancel_queued\|cancel_running_if_safe}` |
| `POST`| `/agents/{agentKey}/enable` | `{reason?}` |
| `POST`| `/agents/pause-all` / `/agents/enable-all` | `{reason?}` — global `*` switch (precedence over per-agent) |

### Agent Runs
| Method | Path | Payload |
|---|---|---|
| `POST`| `/agent-runs` | `{startup_id, agent_key, agent_version?, core_skill_version?, stage_skill_version?, trigger_type: webhook\|schedule\|manual\|retry, trigger_event_id?, correlation_id, idempotency_key, expected_group?, expected_status?, expected_record_version?, parent_run_id?}` → 201 (or 200 if duplicate idempotency_key) |
| `GET` | `/agent-runs` | `?startup_id=&agent_key=&limit=&cursor=` (cursor pagination) |
| `GET` | `/agent-runs/{run}` | + approvalRequests + executionRequests |
| `PATCH`| `/agent-runs/{run}/analysis-result` | `{analysis_status: running\|completed\|failed\|cancelled, decision_payload?, proposed_action?, proposed_status_update?, note_payload?, handoff_id?, error_code?, error_message?}` — rejected once analysis terminal (decision is immutable) |
| `POST`| `/agent-runs/{run}/execution-results` | `{execution_status: executing\|executed\|blocked\|cancelled\|failed, execution_result?, activity_log_id?, notification_result?, error_code?, error_message?}` — execution layer only; if `activity_log_id` numeric, the Numu Logs row gets the `[AI] Run#` badge |
| `POST`| `/agent-runs/{run}/cancel` | `{reason?}` |

**Run response (shape):** the `agent_runs` row — `id, startup_id, agent_key,
analysis_status, execution_status, proposed_action, proposed_status_update,
correlation_id, idempotency_key, started_at, completed_at, …`.

### Action Policies (designed + resolve + overrides)
| Method | Path | Payload / response |
|---|---|---|
| `GET` | `/action-policies` | list designed policies |
| `GET` | `/action-policies/{agentKey}/{actionKey}` | **resolve** — `?environment=prod&startup_id=&test_fixture=` → `{policy, source: override\|designed\|default, version?, override_id?}` |
| `PUT` | `/action-policies/{agentKey}/{actionKey}` | `{policy: always_allow\|needs_approval\|blocked, environment, expected_version?, reason?}` — version-checked (409 on mismatch), writes a new versioned row |
| `POST`| `/action-policy-overrides` | scoped temporary override (startup/environment/test_fixture) |
| `POST`| `/action-policy-overrides/{override}/revoke` | revoke an override |

### Execution Requests (durable idempotency/lock)
| Method | Path | Payload |
|---|---|---|
| `POST`| `/execution-requests` | `{idempotency_key, startup_id, agent_run_id?, source_event_id?, correlation_id, action_key, transition_rule_version?, expected_group?, expected_status?, expected_record_version?, expected_updated_at?, lock_seconds?}` → `{request, duplicate:bool}` |
| `GET` | `/execution-requests/{key}` | read the claim + stored result |
| `POST`| `/execution-requests/{key}/result` | `{status, execution_result?}` — releases the lock |

### Approvals
| Method | Path | Payload |
|---|---|---|
| `GET` | `/approvals` | list (Make polls this) |
| `GET` | `/approvals/{approval}` | detail (+ run, startup, agent, decidedBy) |
| `POST`| `/approvals/{approval}/decide` | `{decision: approve\|reject\|cancel, comment?}` — one-shot |
| — | **create** | **⚠️ no endpoint** — `ApprovalService::createFromRun()` exists but is unwired (see §9) |

### Handoffs (agent → agent context transfer)
`POST /handoffs`, `GET /handoffs/latest`, `GET /handoffs`, `GET /handoffs/{id}`,
`POST /handoffs/{id}/supersede`.

### Admin (human) — session-auth, permission-gated (not for Make)
`GET /admin/approvals` (status + From/To date filters), `GET /admin/approvals/{id}`,
`POST /admin/approvals/{id}/decide`. AI Agents console + Actions screens are under
the admin UI (user-menu → AI Agents; sidebar Core → Actions).

---

## 7. Manual end-to-end testing guide

> Use a Sanctum `naat_…` full token: `Authorization: Bearer naat_…`, `Accept: application/json`.
> Pick a real `startup_id` (e.g. 457). Use a unique `idempotency_key`/`correlation_id` per test.

### Test 1 — always_allow (executes)
```
# 1. set the policy
PUT /api/v1/ai/action-policies/prescreen/request_more_info
{ "policy":"always_allow", "environment":"prod", "reason":"qa" }

# 2. create a run
POST /api/v1/ai/agent-runs
{ "startup_id":457, "agent_key":"prescreen", "trigger_type":"manual",
  "correlation_id":"qa-1", "idempotency_key":"qa-run-1" }

# 3. record the proposal
PATCH /api/v1/ai/agent-runs/{run}/analysis-result
{ "analysis_status":"completed", "proposed_action":"request_more_info" }

# 4. confirm the routing
GET /api/v1/ai/action-policies/prescreen/request_more_info?environment=prod&startup_id=457
→ { "policy":"always_allow", "source":"designed", "version":N }

# 5. claim + (Make) execute native action + record
POST /api/v1/ai/execution-requests { "idempotency_key":"qa-exec-1","startup_id":457,
  "correlation_id":"qa-1","action_key":"request_more_info","agent_run_id":{run} }
POST /api/v1/ai/agent-runs/{run}/execution-results { "execution_status":"executed" }
```
**Expected DB:** 1 `agent_runs` (analysis=completed, execution=executed), 1
`execution_requests` (succeeded), **0** approvals, audit rows for each step.
**Expected UI:** run visible under AI Agents → agent → Recent Runs; **no** approval in /admin/approvals.

### Test 2 — needs_approval (holds for a human)
```
PUT  /action-policies/prescreen/request_more_info { "policy":"needs_approval","environment":"prod" }
POST /agent-runs {…"idempotency_key":"qa-run-2"…}
PATCH /agent-runs/{run}/analysis-result { "analysis_status":"completed","proposed_action":"request_more_info" }
# → an approval (PENDING) should exist for this run  ⚠️ creation endpoint not wired (see §9);
#   to exercise the rest today, create it via tinker:
#   ApprovalService::createFromRun($run, ['action_key'=>'request_more_info','risk_level'=>'expected'])
GET  /api/v1/ai/approvals            → the PENDING row
POST /api/v1/ai/approvals/{id}/decide { "decision":"approve", "comment":"ok" }
```
**Expected DB:** `agent_approval_requests` PENDING → APPROVED (decided_by/at set);
startup unchanged until Make executes after approve. **Expected UI:** the approval shows
in /admin/approvals (Pending → Approved); opening it stamps the timeline.
**Reject path:** `{"decision":"reject"}` → REJECTED, nothing executes.

### Test 3 — blocked
```
PUT  /action-policies/prescreen/request_more_info { "policy":"blocked","environment":"prod" }
GET  /action-policies/prescreen/request_more_info?environment=prod  → { "policy":"blocked" }
POST /agent-runs/{run}/execution-results { "execution_status":"blocked" }
```
Also test **default-blocked**: resolve a pair the agent doesn't own, e.g.
`GET /action-policies/prescreen/move_to_listing?environment=prod` → `{policy:"blocked",source:"default"}`.
**Expected:** no approval, no execution; run execution_status=blocked; audit `run_execution(blocked)`.

---

## 8. QA checklist

**AI Agents** ☐ register (UI + `POST /agents`) ☐ Startup/Investor tabs filter by type
☐ pause/enable per-agent ☐ global pause overrides per-agent ☐ version-conflict → 409.
**Actions** ☐ list per tab ☐ inline AR/EN name edit saves ☐ Notify/Enabled toggles persist
☐ drag reorder persists ☐ New Action modal creates (tab-aware) ☐ delete.
**Policies** ☐ resolve override→designed→default ☐ PUT writes new version ☐ unassigned→blocked
☐ Assigned Actions add/remove/change ☐ per-action policy screen shows only assigned agents.
**Approvals** ☐ list + status filter + From/To date filter (URL params) ☐ detail timeline
☐ approve/reject/cancel one-shot ☐ expired by scheduler.
**Startup actions** ☐ each native action changes `action_option_id` ☐ fires notifications/group move.
**Investor actions** ☐ catalog supports them ☐ (none seeded — expected).
**Native actions** ☐ executed via existing admin/native path ☐ idempotent (duplicate claim no-ops).
**Audit** ☐ a row for every create/decision/policy-set/run/execution.
**Make integration** ☐ token auth `numu:full` ☐ idempotency keys dedupe ☐ poll approvals.
**Errors** ☐ analysis after terminal → 409 ☐ re-decide settled approval → 409 ☐ unknown agent/action → blocked ☐ duplicate idempotency_key → original returned.

---

## 9. Implementation status — complete / partial / missing

**✅ Complete**
- 8-table managed-agents schema + models; typed agents (`agent_type`).
- Agent registry (API `make_api` + admin UI `admin_ui` + seeder `seed`).
- Runtime enable/pause (global `*` + per-agent, version concurrency → 409).
- Action policies: designed (versioned history) + runtime overrides + resolution
  (override → designed → **default blocked**).
- Agent-run ledger (idempotent; decision/execution separation + immutability).
- Execution-request durable idempotency/lock store.
- Approval model + **decide** (one-shot) + list/show + full admin UI (timeline, status + date filters).
- Handoffs; audit logging on every mutation.
- Seeded 7 startup agents + canonical startup action options + assignments.
- Admin UIs: AI Agents (tabs, Assigned Actions), Actions (grid, compact policy summary,
  per-action Action Agent Policies screen, New Action), Approvals.

**🟡 Partial / dormant**
- **Approval creation is not wired to an endpoint.** `ApprovalService::createFromRun()`
  exists but no controller/route calls it → Make cannot create an approval today.
  Needs either a `POST /api/v1/ai/approvals` or auto-creation inside `analysis-result`
  when the resolved policy is `needs_approval`. (Today: 0 approvals in DB.)
- **No Numu-side orchestration.** `resolve()` is a query; Numu does not auto-branch into
  approval/execution. This is **by design** (Make orchestrates) — but it means the
  end-to-end loop only works when Make is wired.
- **API agent registration ignores `agent_type`** (defaults `startup`); only the admin UI
  sets type. Add `agent_type` to `POST /agents` for Make-created investor agents.
- **Native-action bridge:** executing the actual startup change relies on Make calling the
  existing admin/native-business-action path; there's no automatic bridge from an approved
  run to `StartupLabelActionService`.

**🔴 Missing / waiting on Make**
- **Make blueprints not connected** — 0 runs / 0 approvals / 0 execution_requests; the
  pipeline is built but never exercised end-to-end.
- **Investor agents** — none seeded; investor action policies + journey undefined.
- **Auto-execute on approve** — Make must poll approvals, re-validate state, execute.
- Optional: scheduled approval expiry is implemented (`approvals:expire`) but must be cron-scheduled in prod.

---

## 10. Sequence diagrams

### always_allow
```
Make ───POST /agent-runs──────────────▶ Numu: agent_runs (queued)               ▶ audit run_created
Make ───PATCH analysis-result─────────▶ Numu: proposed_action set               ▶ audit run_analysis
Make ───GET action-policies/a/x───────▶ PolicyEngine.resolve → "always_allow"
Make ───POST /execution-requests──────▶ Numu: execution_requests (processing)   ▶ audit execution_claimed
Make ──(execute Native Action via admin/native API)─▶ startup.action_option_id changed
                                                      └▶ StartupLabelOptionChanged → notifications / group move
Make ───POST /execution-requests/{k}/result─▶ succeeded                          ▶ audit execution_result
Make ───POST /agent-runs/{r}/execution-results (executed)─▶                      ▶ audit run_execution
   (Approval Engine: NOT involved)
```

### needs_approval
```
Make ───POST /agent-runs──────────────▶ agent_runs (queued)                     ▶ audit run_created
Make ───PATCH analysis-result─────────▶ proposed_action set                     ▶ audit run_analysis
Make ───GET action-policies/a/x───────▶ resolve → "needs_approval"
        ApprovalEngine.createFromRun ─▶ agent_approval_requests (PENDING)        ▶ audit approval_created
                                          (⚠️ creation endpoint not wired yet)
   ── HOLD — Native Action does NOT run ──
Admin ──opens /admin/approvals/{id}───▶ viewed_at set (timeline)
Admin ──POST decide {approve}─────────▶ APPROVED (decided_by/at)                 ▶ audit approval_approve
Make ───GET /approvals (poll)─────────▶ sees APPROVED → re-validate state
Make ──(execute Native Action)────────▶ startup changes → notifications/group move
Make ───POST execution-results (executed)▶                                       ▶ audit run_execution
   [reject path] decide {reject} → REJECTED ▶ audit approval_reject → nothing executes
```

### blocked
```
Make ───POST /agent-runs──────────────▶ agent_runs (queued)                     ▶ audit run_created
Make ───PATCH analysis-result─────────▶ proposed_action set                     ▶ audit run_analysis
Make ───GET action-policies/a/x───────▶ resolve → "blocked"  (designed OR default-unassigned)
   ── Native Action NOT executed · Approval NOT created ──
Make ───POST execution-results (blocked)▶ agent_runs.execution_status=blocked    ▶ audit run_execution(blocked)
```
