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, comments, and Skills. 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-dropletinternalAccessis enforced at retrieval time, never via post-filter. - Shared system prompt —
Agent.systemPromptis editable by Space admins. Customizes voice and norms for the whole Space ("answer in two sentences," "always cite a source," etc.). Lives on theAgentrow, NOT on each conversation. - Shared tool surface — the v1 tools listed below;
Agent.enabledToolsoptionally 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 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-extractused by full-text search) - Skill droplet bodies (
appType: SKILL,sourceFormat: MARKDOWN) - Threaded comments on published droplets
What does NOT get ingested:
- Drafts —
lib/drafts.tsis the privacy chokepoint and the agent only sees them via the explicitgetMyDrafttool, 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 bycanReadAppto 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 vialib/drafts.tschokepoint).
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
Every Agent carries these attributes.
Attributes
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.SPACE-scoped agents, the Space this Agent belongs to. Unique (a Space has at most one Agent — FR-002). Null when scope = USER (v2).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.null on update to clear back to default. Changes take effect on the very next message — no per-conversation cache.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 — unknown tool ids return INVALID_TOOL_NAME.More attributes+ Show 2− Hide
spaceSlug (path) + the implicit caller (header). Surfaces in audit events and the dashboard tables.{
"id": "agent_abc123",
"scope": "SPACE",
"spaceId": "spc_drop",
"userId": null,
"systemPrompt": null,
"enabledTools": [],
"enabledAt": "2026-06-06T15:30:00.000Z",
"createdById": "usr_jonathan"
}Methods
Endpoints to operate on a Agent. The example switches between curl, drop CLI, and MCP via the dropdown — your choice persists across the page.
Get the Space Agent
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).
drop agent show drop{
"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
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.
drop agent enable{
"agent": {
"id": "example",
"scope": "SPACE",
"spaceId": "example",
"userId": "example",
"systemPrompt": "example",
"enabledTools": [],
"enabledAt": "example",
"createdById": "example"
}
}Update the Space Agent
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.
drop agent update{
"agent": {
"id": "example",
"scope": "SPACE",
"spaceId": "example",
"userId": "example",
"systemPrompt": "example",
"enabledTools": [],
"enabledAt": "example",
"createdById": "example"
}
}Disable the Space Agent
Delete the Agent for this Space, cascading conversations + messages. AgentEvent rows are preserved (SetNull) so cost history survives. Only Space admins. Cannot be undone.
drop agent disable{
"success": null
}List the caller's 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.
drop agent conversations list{
"conversations": []
}Create a new conversation
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.
drop agent conversations create{
"conversation": {
"id": "example",
"agentId": "example",
"userId": "example",
"title": "example",
"createdAt": "example",
"updatedAt": "example",
"archivedAt": "example"
}
}Get a conversation thread
Return a conversation thread and its full message history. Privacy: returns CONVERSATION_NOT_FOUND for both non-existent AND others' threads — never leaks existence.
drop agent conversations show{
"conversation": {
"id": "example",
"agentId": "example",
"userId": "example",
"title": "example",
"createdAt": "example",
"updatedAt": "example",
"archivedAt": "example"
},
"messages": []
}Update a conversation thread
Rename or archive a conversation thread. Pass null to archivedAt to unarchive.
drop agent conversations update{
"conversation": {
"id": "example",
"agentId": "example",
"userId": "example",
"title": "example",
"createdAt": "example",
"updatedAt": "example",
"archivedAt": "example"
}
}Delete a conversation thread
Permanently delete a conversation thread and its messages. Telemetry events (AgentEvent rows) are preserved via SetNull. Cannot be undone.
drop agent conversations delete{
"success": null
}