Core Resources
View as Markdown

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

GateWhere it livesDefaultWho controls itFailure code
GlobalNEXT_PUBLIC_SPACE_AGENT_ENABLED env varfalse in prod, true in stagingSys admin (next.config.js + Amplify env)FEATURE_DISABLED_GLOBAL (403)
Per-userUser.spaceAgentEnabled columnfalseSys admin (toggles per-account dogfood gate)FEATURE_DISABLED_FOR_USER (403)
Per-SpacePresence of an Agent row for the SpaceabsentSpace 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 varDefaultWhat it gates
SPACE_AGENT_DAILY_USER_TOKEN_CAP500_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_CAP100_000 tokens / conversationSum across the entire AgentConversation thread
SPACE_AGENT_BUDGET_WARN_PCT80 (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

ModelWhat it isLifecycle
AgentThe 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.
AgentConversationA private thread between an Agent and one User. Many threads per pair.Cascades messages on delete; events SetNull.
AgentMessageOne turn / tool-call / tool-result. role enum: USER / ASSISTANT / TOOL_CALL / TOOL_RESULT / THINKING. content is Mastra-shaped JSON.Cascade-deleted with the conversation.
AgentEventAppend-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

scope
enum
SPACEUSER
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. 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 — unknown tool ids return INVALID_TOOL_NAME.
enabledAt
timestamp
ISO 8601 timestamp the Agent was first enabled (= the row created). Set once at Enable; never changes. Useful for distinguishing fresh enables from idempotent re-enables (which return the existing row).
More attributes+ Show 2
id
string
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
The User who first ran 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.
THE AGENT OBJECT
{
  "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

Get the Space Agent

/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).

GET/api/agents/{spaceSlug}
drop agent show drop
Response200
{
  "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": []
}
POST

Enable the Space Agent

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

POST/api/agents/{spaceSlug}
drop agent enable
Response200
{
  "agent": {
    "id": "example",
    "scope": "SPACE",
    "spaceId": "example",
    "userId": "example",
    "systemPrompt": "example",
    "enabledTools": [],
    "enabledAt": "example",
    "createdById": "example"
  }
}
PATCH

Update the Space Agent

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

PATCH/api/agents/{spaceSlug}
drop agent update
Response200
{
  "agent": {
    "id": "example",
    "scope": "SPACE",
    "spaceId": "example",
    "userId": "example",
    "systemPrompt": "example",
    "enabledTools": [],
    "enabledAt": "example",
    "createdById": "example"
  }
}
DELETE

Disable the Space Agent

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

DELETE/api/agents/{spaceSlug}
drop agent disable
Response200
{
  "success": null
}
GET

List the caller's conversations

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

GET/api/agents/{spaceSlug}/conversations
drop agent conversations list
Response200
{
  "conversations": []
}
POST

Create a new conversation

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

POST/api/agents/{spaceSlug}/conversations
drop agent conversations create
Response200
{
  "conversation": {
    "id": "example",
    "agentId": "example",
    "userId": "example",
    "title": "example",
    "createdAt": "example",
    "updatedAt": "example",
    "archivedAt": "example"
  }
}
GET

Get a conversation thread

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

GET/api/agents/{spaceSlug}/conversations/{convId}
drop agent conversations show
Response200
{
  "conversation": {
    "id": "example",
    "agentId": "example",
    "userId": "example",
    "title": "example",
    "createdAt": "example",
    "updatedAt": "example",
    "archivedAt": "example"
  },
  "messages": []
}
PATCH

Update a conversation thread

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

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

PATCH/api/agents/{spaceSlug}/conversations/{convId}
drop agent conversations update
Response200
{
  "conversation": {
    "id": "example",
    "agentId": "example",
    "userId": "example",
    "title": "example",
    "createdAt": "example",
    "updatedAt": "example",
    "archivedAt": "example"
  }
}
DELETE

Delete a conversation thread

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

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

DELETE/api/agents/{spaceSlug}/conversations/{convId}
drop agent conversations delete
Response200
{
  "success": null
}