---
title: Agent
kind: resource
slug: agent
summary: "A per-Space AI agent with shared knowledge of the Space's droplets, comments, and skills. Members hold private conversation threads; the Claude Projects mental model."
---

# Agent

A per-Space AI agent with shared knowledge of the Space's droplets, comments, and skills. Members hold private conversation threads; the Claude Projects mental model.

An **Agent** is a per-Space AI entity that knows about the Space's published [Droplets](/docs/resources/droplet), comments, and [Skills](/docs/resources/skill). Every member of the Space sees the same agent — same shared system prompt, same tool surface, same knowledge base — but each member's **conversations** with that agent are private to them (Claude Projects mental model: shared brain, private threads).

`Agent` is **polymorphic from day one** via a `scope: SPACE | USER` discriminator. v1 ships only `SPACE`-scoped rows; v2 unlocks personal cross-Space agents (`USER` scope) as a code change, not a migration. Exactly one of `spaceId` / `userId` is set, enforced by app code + a CHECK constraint in the migration.

## The Claude Projects mental model

The agent's identity has three pieces that live on the `Agent` row itself and are shared across every member of the Space:

- **Shared knowledge** — the Bedrock Knowledge Base (corpus of published droplets + comments + skill bodies in the Space). Refreshed at the publish edge in `lib/publish.ts`; never contains drafts. Per-droplet `internalAccess` is enforced at retrieval time, never via post-filter.
- **Shared system prompt** — `Agent.systemPrompt` is editable by Space admins. Customizes voice and norms for the whole Space ("answer in two sentences," "always cite a source," etc.). Lives on the `Agent` row, NOT on each conversation.
- **Shared tool surface** — the v1 tools listed below; `Agent.enabledTools` optionally restricts to a subset (empty = all).

What is **private per (agent, user)** is the conversation graph: `AgentConversation` threads and their `AgentMessage` history. Two members of the same Space have completely separate thread lists; no admin path exposes another member's threads (privacy invariant — `CONVERSATION_NOT_FOUND` is returned for both "doesn't exist" and "exists but not yours" so the API never leaks existence).

**Why per-Space at v1**: Space membership is already the unit of trust for the corpus. Adding cross-Space (`USER` scope) requires either union retrieval across multiple Spaces or a personal corpus — both heavier than the v1 win. The polymorphic shape costs ~5 nullable fields today and unlocks v2 (User Agent) with code-only changes — a classic "design for the second use case before you have it" call (research §5).

## Three-layer feature gating

Per `feedback_yagni_db_feature_flags.md`, no new flag system — three orthogonal gates that all must be open for any agent surface to appear. Closing any one of them removes the surface entirely (UI, CLI, MCP, API).

| Gate | Where it lives | Default | Who controls it | Failure code |
|---|---|---|---|---|
| **Global** | `NEXT_PUBLIC_SPACE_AGENT_ENABLED` env var | `false` in prod, `true` in staging | Sys admin (next.config.js + Amplify env) | `FEATURE_DISABLED_GLOBAL` (403) |
| **Per-user** | `User.spaceAgentEnabled` column | `false` | Sys admin (toggles per-account dogfood gate) | `FEATURE_DISABLED_FOR_USER` (403) |
| **Per-Space** | Presence of an `Agent` row for the Space | absent | Space admin (`drop agent enable`) | `AGENT_NOT_ENABLED` (404) |

Short-circuit order is global → per-user → per-Space. The clear human message for each layer (which type of admin to ask) is part of the contract — see the [error codes](#errors) below.

## Cost guardrails

Originally speced as deferred to v2; added during build at Jonathan's request. Two layered token caps + a warning threshold; only the streaming `/run` endpoint enforces them. Read / manage routes opt out via `enforceBudget: false` so a user over cap can still see and prune their own threads.

| Env var | Default | What it gates |
|---|---|---|
| `SPACE_AGENT_DAILY_USER_TOKEN_CAP` | `500_000` tokens / 24h / user (≈ $1.50) | Sum of `tokensIn + tokensOut` from this user's `AgentEvent` rows in the trailing 24h |
| `SPACE_AGENT_PER_CONV_TOKEN_CAP` | `100_000` tokens / conversation | Sum across the entire `AgentConversation` thread |
| `SPACE_AGENT_BUDGET_WARN_PCT` | `80` (percent) | Threshold at which the UI surfaces a soft warning; no enforcement |

When either cap is exceeded, the gate throws `BUDGET_EXCEEDED` (HTTP 429) with a human-readable reason ("Daily token limit reached — resets in 7h" or "This conversation has used its token cap — start a new one"). Setting either env var to `0` disables that cap entirely.

## Knowledge corpus + retrieval

The agent's grounding comes from **Bedrock Knowledge Base on S3 Vectors** — a single org-wide KB metadata-filtered by `spaceId` rather than one KB per Space. Embedding model is **Titan v2 @ 1024 dims**, chunking is `FIXED_SIZE` at **512 tokens**, ingestion is **direct** (no S3 sync) fired from the `lib/publish.ts` chokepoint on every publish / republish / rollback / hard-delete.

What gets ingested:

- Published droplets (HTML extracted to plain text via the same `lib/text-extract` used by full-text search)
- Skill droplet bodies (`appType: SKILL`, `sourceFormat: MARKDOWN`)
- Threaded comments on published droplets

What does **NOT** get ingested:

- Drafts — `lib/drafts.ts` is the privacy chokepoint and the agent only sees them via the explicit `getMyDraft` tool, scoped to the caller's own drafts
- Older versions — only the currently-live published version is in the corpus
- Hard-deleted droplets — the publish-edge fires a delete on the KB document within 60 seconds (FR-009)

Retrieval enforces the caller's per-droplet `internalAccess` at query time as an `orAll` filter — `internalAccess IN [EVERYONE, SPACE]` OR `ownerId == caller`. Stricter `PRIVATE` / `SPECIFIC` droplets the caller doesn't have access to are simply not returned; the agent doesn't know they exist (FR-007).

## HITL discipline

The agent must never silently mutate state. v1 enforces this via the **system prompt** — the model is instructed to ask the user for explicit confirmation before calling any mutating tool (`postComment`, `addTag`). Publishing a draft is even stricter: the agent **never** publishes; the only path to live state change is the user clicking Publish in the viewer iframe (FR-013).

v2 may add a `confirmed: boolean` tool arg + a coral-bordered confirmation card UI for stronger guarantees. v1 ships the prompt-level chokepoint because adding card UI mid-stream conflicted with the SSE encoder's event shape.

## Tool surface (v1)

Ten tools, registered per-request in `packages/web/src/lib/agent/tools/index.ts`. Each tool's `execute` closure captures the caller's `userId` + `spaceId` — no shared mutable state across users.

**Phase 1 — read baseline:**

- `searchKnowledge` — semantic search the Space's KB; returns chunks + droplet citations.
- `listDroplets` — list droplets in the Space the caller can read.
- `getDroplet` — full droplet metadata by slug.
- `getDropletText` — extracted plain-text body for one droplet.

**Phase 2 — skills + comments + tags + own drafts:**

- `listSkills` — skills in this Space + featured / library-tagged skills org-wide (gated by `canReadApp` to plug the leak Bugbot caught in PR #561).
- `getSkill` — read a skill body as procedural knowledge.
- `listComments` — threaded comments on a droplet the caller can read.
- `postComment` — **HITL**: prompt-gated; model asks before posting.
- `addTag` — **HITL**: prompt-gated; model asks before tagging.
- `getMyDraft` — read the caller's own draft for a slug (author-only privacy gate via `lib/drafts.ts` chokepoint).

**Deferred to Phase 2.5**: `createDraft` / `editDraft` — both need real S3 upload workflows that don't fit the read-only tool pattern. v1's system prompt routes "make me a new droplet" to the dashboard's deploy zone or `drop create`.

## Streaming run endpoint

The `POST /api/agents/{spaceSlug}/run` endpoint is the agent's primary surface — but it's **not a contract Operation**. It's a hand-written SSE endpoint using the AG-UI event protocol (`@ag-ui/encoder` + `@ag-ui/core`) wrapped around a per-request Mastra Agent backed by Bedrock Converse via `@ai-sdk/amazon-bedrock`. SSE / AG-UI doesn't fit the contract layer's JSON-in / JSON-out assumption.

The CLI `drop agent ask` and `drop agent chat` verbs and the web panel both consume this endpoint; MCP does not at v1.

## Telemetry

`AgentEvent` is an append-only log — one row per conversation start, message sent, tool call, KB query, HITL decision, ingestion event, and error. Each row carries scope identifiers (`agentId`, `conversationId`, `userId`, `spaceId`), `tokensIn`/`tokensOut`, `costCents`, `latencyMs`, and an event-specific `data` JSON blob.

The same table powers three dashboards (per-user / per-Space / sys-admin) at `/admin/metrics`:

- **AI Agents tab** — engagement signals: active users, message volume, tool-call mix, HITL approval rate, latency, $ spend.
- **Knowledge Base tab** — ops signals: ingestion success rate, retrieve p99 latency, vector count by Space.
- **Per-user "Your activity"** — surfaced inside the agent panel.

CloudWatch alarms live in `infra/cdk/lib/knowledge-base.ts` and emit to the `Drop/SpaceAgent` custom-metric namespace via `lib/cw-metrics.ts`. Five alarms wired today: Bedrock 4xx, Bedrock 5xx, KB ingest failures, KB retrieve p99 latency, agent daily spend. SNS topic ARN is read from `CW_ALARM_TOPIC_ARN` — unset = alarms exist but silent.

## Related models

| Model | What it is | Lifecycle |
|---|---|---|
| **`Agent`** | The per-Space (eventually per-user) AI entity. 1:1 with its scope target. | Disable cascades `AgentConversation` + `AgentMessage`; `AgentEvent` rows survive via `SetNull` to preserve cost history. |
| **`AgentConversation`** | A private thread between an Agent and one User. Many threads per pair. | Cascades messages on delete; events SetNull. |
| **`AgentMessage`** | One turn / tool-call / tool-result. `role` enum: `USER` / `ASSISTANT` / `TOOL_CALL` / `TOOL_RESULT` / `THINKING`. `content` is Mastra-shaped JSON. | Cascade-deleted with the conversation. |
| **`AgentEvent`** | Append-only telemetry log. Powers all three dashboards. | Never deleted; nullable scope IDs survive parent deletes via `SetNull`. |

## The Agent object

### Attributes

- **`scope`** `enum` (enum: `SPACE` / `USER`) — **Polymorphic discriminator.** v1 ships only `SPACE`-scoped rows (one Agent per Space, shared by all members). `USER` scope is reserved for the v2 personal cross-Space agent; the column exists today so v2 ships without a migration. Exactly one of `spaceId` / `userId` is set, enforced by app code + a CHECK constraint.
- **`spaceId`** `string · nullable` — For `SPACE`-scoped agents, the Space this Agent belongs to. Unique (a Space has at most one Agent — FR-002). Null when `scope = USER` (v2).
- **`userId`** `string · nullable` — For `USER`-scoped agents, the owner User. Unique (a user has at most one personal Agent). Null when `scope = SPACE` (v1 always). Reserved for v2 — the User Agent track in research §5e.
- **`systemPrompt`** `string · nullable` — Optional shared custom instructions for the Agent. Editable by Space admins via [Update](#update). Concatenated with the built-in system prompt at request time. Max 8000 chars. Pass `null` on update to clear back to default. Changes take effect on the very next message — no per-conversation cache.
- **`enabledTools`** `string[]` — **Tool allow-list.** Empty array (the default) means "all v1 tools are available." A non-empty subset of `ALL_TOOL_IDS` (`searchKnowledge`, `listDroplets`, `getDroplet`, `getDropletText`, `listSkills`, `getSkill`, `listComments`, `postComment`, `addTag`, `getMyDraft`) restricts the surface for this Space. Validated against the registry on [Update](#update) — unknown tool ids return `INVALID_TOOL_NAME`.
- **`enabledAt`** `timestamp` — ISO 8601 timestamp the Agent was first enabled (= the row created). Set once at [Enable](#enable); never changes. Useful for distinguishing fresh enables from idempotent re-enables (which return the existing row).
- **`id`** `string · less-common` — CUID primary key. Used internally; APIs are addressed by `spaceSlug` (path) + the implicit caller (header). Surfaces in audit events and the dashboard tables.
- **`createdById`** `string · less-common` — The User who first ran [Enable](#enable). Distinct from a notion of "owner" — there is no owner role for Spaces with an Agent; control lives on the Space's admin roster.

### JSON example

```json
{
  "id": "agent_abc123",
  "scope": "SPACE",
  "spaceId": "spc_drop",
  "userId": null,
  "systemPrompt": null,
  "enabledTools": [],
  "enabledAt": "2026-06-06T15:30:00.000Z",
  "createdById": "usr_jonathan"
}
```

## Methods

### Get the Space Agent

`GET /api/agents/{spaceSlug}`

Return the Agent configured for this Space, plus the caller's recent conversations in this Space. The agent is per-(Space, member): shared knowledge / system prompt / tools across all members, private conversation history per caller (Claude Projects model).

**CLI**

```bash
drop agent show drop
```

**MCP**

```json
{
  "tool": "drop_agent_get",
  "arguments": {
    "spaceSlug": "drop"
  }
}
```

**HTTP**

```bash
curl -X GET 'https://drop.himarley.ai/api/agents/drop' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "agent": {
    "id": "agent_abc123",
    "scope": "SPACE",
    "spaceId": "spc_drop",
    "userId": null,
    "systemPrompt": null,
    "enabledTools": [],
    "enabledAt": "2026-06-06T15:30:00.000Z",
    "createdById": "usr_jonathan"
  },
  "recentConversations": []
}
```

### Enable the Space Agent

`POST /api/agents/{spaceSlug}`

Provision an Agent for this Space. Idempotent — if the Space already has an Agent, returns the existing row with status 200 (no error). Only Space admins can call this.

**CLI**

```bash
drop agent enable
```

**MCP**

```json
{
  "tool": "drop_agent_enable",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X POST 'https://drop.himarley.ai/api/agents/{spaceSlug}' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "agent": {
    "id": "example",
    "scope": "SPACE",
    "spaceId": "example",
    "userId": "example",
    "systemPrompt": "example",
    "enabledTools": [],
    "enabledAt": "example",
    "createdById": "example"
  }
}
```

### Update the Space Agent

`PATCH /api/agents/{spaceSlug}`

Edit the Space Agent's shared system prompt and / or restrict the tool surface. Pass null to clear a field back to default. Only Space admins. Changes take effect on the very next message in any conversation in this Space.

**CLI**

```bash
drop agent update
```

**MCP**

```json
{
  "tool": "drop_agent_update",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X PATCH 'https://drop.himarley.ai/api/agents/{spaceSlug}' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "agent": {
    "id": "example",
    "scope": "SPACE",
    "spaceId": "example",
    "userId": "example",
    "systemPrompt": "example",
    "enabledTools": [],
    "enabledAt": "example",
    "createdById": "example"
  }
}
```

### Disable the Space Agent

`DELETE /api/agents/{spaceSlug}`

Delete the Agent for this Space, cascading conversations + messages. AgentEvent rows are preserved (SetNull) so cost history survives. Only Space admins. **Cannot be undone.**

**CLI**

```bash
drop agent disable
```

**MCP**

```json
{
  "tool": "drop_agent_disable",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X DELETE 'https://drop.himarley.ai/api/agents/{spaceSlug}' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "success": null
}
```

### List the caller's conversations

`GET /api/agents/{spaceSlug}/conversations`

Return the caller's conversation threads with the Space Agent. Privacy invariant: server filters by caller userId — there is no admin path to read another user's threads. `archived=true` shows archived threads only; default is non-archived.

**CLI**

```bash
drop agent conversations list
```

**MCP**

```json
{
  "tool": "drop_agent_conversations_list",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X GET 'https://drop.himarley.ai/api/agents/{spaceSlug}/conversations' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "conversations": []
}
```

### Create a new conversation

`POST /api/agents/{spaceSlug}/conversations`

Open a new conversation thread with the Space Agent. The thread is private to the caller. Most callers won't need this directly — the CLI `agent ask` / `chat` verbs auto-create a conversation if `--continue <id>` isn't passed.

**CLI**

```bash
drop agent conversations create
```

**MCP**

```json
{
  "tool": "drop_agent_conversations_create",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X POST 'https://drop.himarley.ai/api/agents/{spaceSlug}/conversations' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "conversation": {
    "id": "example",
    "agentId": "example",
    "userId": "example",
    "title": "example",
    "createdAt": "example",
    "updatedAt": "example",
    "archivedAt": "example"
  }
}
```

### Get a conversation thread

`GET /api/agents/{spaceSlug}/conversations/{convId}`

Return a conversation thread and its full message history. Privacy: returns CONVERSATION_NOT_FOUND for both non-existent AND others' threads — never leaks existence.

**CLI**

```bash
drop agent conversations show
```

**MCP**

```json
{
  "tool": "drop_agent_conversations_get",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X GET 'https://drop.himarley.ai/api/agents/{spaceSlug}/conversations/{convId}' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "conversation": {
    "id": "example",
    "agentId": "example",
    "userId": "example",
    "title": "example",
    "createdAt": "example",
    "updatedAt": "example",
    "archivedAt": "example"
  },
  "messages": []
}
```

### Update a conversation thread

`PATCH /api/agents/{spaceSlug}/conversations/{convId}`

Rename or archive a conversation thread. Pass null to archivedAt to unarchive.

**CLI**

```bash
drop agent conversations update
```

**MCP**

```json
{
  "tool": "drop_agent_conversations_update",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X PATCH 'https://drop.himarley.ai/api/agents/{spaceSlug}/conversations/{convId}' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "conversation": {
    "id": "example",
    "agentId": "example",
    "userId": "example",
    "title": "example",
    "createdAt": "example",
    "updatedAt": "example",
    "archivedAt": "example"
  }
}
```

### Delete a conversation thread

`DELETE /api/agents/{spaceSlug}/conversations/{convId}`

Permanently delete a conversation thread and its messages. Telemetry events (AgentEvent rows) are preserved via SetNull. **Cannot be undone.**

**CLI**

```bash
drop agent conversations delete
```

**MCP**

```json
{
  "tool": "drop_agent_conversations_delete",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X DELETE 'https://drop.himarley.ai/api/agents/{spaceSlug}/conversations/{convId}' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "success": null
}
```
