Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save markturansky/1e80a5733a026a22d9633a71b7e94da2 to your computer and use it in GitHub Desktop.

Select an option

Save markturansky/1e80a5733a026a22d9633a71b7e94da2 to your computer and use it in GitHub Desktop.
Agent data model for Ambient
# Spec: Blackboard Coordination API
**Date:** 2026-03-10
**Status:** Approved for Implementation
**Author:** API Agent
---
## Overview
The Ambient API server gains a **Blackboard** coordination layer: persistent agents, coordination check-ins, project-scoped documents, agent-to-agent messaging, a live SSE fleet dashboard, and a native RBAC system.
The central model: **`Agent` and `Session` are distinct entities.** An Agent is a persistent definition that can be ignited into many Sessions over its lifetime. Sessions are ephemeral Kubernetes execution runs. This separation enables re-ignition, run history, fleet persistence, and collaborative sharing.
---
## Data Model
### Entity Relationship Diagram
```mermaid
erDiagram
%% ── Existing (unchanged) ─────────────────────────────────────────────────
User {
string ID PK
string username
string name
string email
time created_at
time updated_at
time deleted_at
}
Project {
string ID PK "name-as-ID"
string name
string display_name
string description
string labels
string annotations
string status
time created_at
time updated_at
time deleted_at
}
ProjectSettings {
string ID PK
string project_id FK
string group_access
string repositories
time created_at
time updated_at
time deleted_at
}
%% ── Agent (persistent definition) ───────────────────────────────────────
Agent {
string ID PK
string project_id FK
string parent_agent_id FK "nullable — fleet hierarchy"
string owner_user_id FK
string name
string display_name
string description
string prompt
string repo_url
string workflow_id
string llm_model
float llm_temperature
int llm_max_tokens
string bot_account_name
string resource_overrides
string environment_variables
string labels
string annotations
string current_session_id FK "nullable — denormalized for fast reads"
time created_at
time updated_at
time deleted_at
}
%% ── Session (ephemeral run) ──────────────────────────────────────────────
Session {
string ID PK
string agent_id FK
string triggered_by_user_id FK "who pressed ignite"
string parent_session_id FK "nullable — sub-session spawning"
string phase
time start_time
time completion_time
string kube_cr_name
string kube_cr_uid
string kube_namespace
string sdk_session_id
int sdk_restart_count
string conditions
string reconciled_repos
string reconciled_workflow
time created_at
time updated_at
time deleted_at
}
%% ── SessionMessage (AG-UI event stream) ─────────────────────────────────
SessionMessage {
string ID PK
string session_id FK
int seq
string event_type
string payload
time created_at
}
%% ── Blackboard ───────────────────────────────────────────────────────────
SessionCheckIn {
string ID PK
string session_id FK
string agent_id FK "denormalized — enables O(agents) Blackboard queries"
string summary
string branch
string worktree
string pr
string phase
int test_count
jsonb items
jsonb questions
jsonb blockers
string next_steps
time created_at
time updated_at
time deleted_at
}
ProjectDocument {
string ID PK
string project_id FK
string slug
string title
text content
time created_at
time updated_at
time deleted_at
}
AgentMessage {
string ID PK
string recipient_agent_id FK
string sender_agent_id FK
string sender_name "denormalized"
text body
bool read
time created_at
time updated_at
time deleted_at
}
%% ── RBAC ─────────────────────────────────────────────────────────────────
Role {
string ID PK
string name
string display_name
string description
jsonb permissions
bool built_in
time created_at
time updated_at
time deleted_at
}
RoleBinding {
string ID PK
string user_id FK
string role_id FK
string scope "global | project | agent | session"
string scope_id "empty for global"
time created_at
time updated_at
time deleted_at
}
%% ── Relationships ────────────────────────────────────────────────────────
Project ||--o{ ProjectSettings : "has"
Project ||--o{ Agent : "contains"
Project ||--o{ ProjectDocument : "documents"
User ||--o{ Agent : "owns"
Agent ||--o{ Agent : "parent_of"
Agent ||--o{ Session : "runs"
Agent ||--o| Session : "current_session"
Agent ||--o{ AgentMessage : "receives"
Agent ||--o{ AgentMessage : "sends"
Agent ||--o{ SessionCheckIn : "reports"
Session ||--o{ SessionMessage : "streams"
Session ||--o{ SessionCheckIn : "has"
Session ||--o| Session : "parent_of"
User ||--o{ RoleBinding : "bound_to"
Role ||--o{ RoleBinding : "granted_by"
```
---
## Agent vs Session
The existing `Session` model conflates two distinct concerns. This spec separates them:
| Category | Fields | Entity |
|---|---|---|
| **Identity** | `name`, `prompt`, `repo_url`, `llm_model`, `llm_temperature`, `llm_max_tokens`, `bot_account_name`, `owner_user_id`, `project_id`, `labels`, `annotations`, `resource_overrides`, `environment_variables`, `workflow_id` | `Agent` — persists forever |
| **Run state** | `phase`, `start_time`, `completion_time`, `kube_cr_name`, `kube_cr_uid`, `kube_namespace`, `sdk_session_id`, `sdk_restart_count`, `conditions`, `reconciled_repos`, `reconciled_workflow` | `Session` — ephemeral |
### Agent Lifecycle
```
Agent ──ignite──► Session ──runs──► completes / fails
│ │
│◄── current_session_id (denormalized)
└── ignite again ──► new Session
prior Session preserved in history
```
`current_session_id` is a denormalized pointer updated on every ignite and on session completion. It enables the Blackboard snapshot to read `Agent + latest SessionCheckIn` without joining through sessions.
### Hierarchical Fleets
`parent_agent_id` makes fleet topologies first-class:
```
Project
└── Agent: "Overlord"
├── Agent: "API"
├── Agent: "FE"
└── Agent: "CP"
└── Agent: "Reviewer"
```
Each Agent has its own run history, inbox, and check-in timeline. The Blackboard renders this as a collapsible tree.
### Sessions are not directly creatable
Sessions are created exclusively via `POST /agents/{id}/ignite`. They are run artifacts, not first-class resources. Direct `POST /sessions` is removed.
---
## Models
### `Agent`
```go
type Agent struct {
api.Meta
ProjectID string `json:"project_id" gorm:"not null;index"`
ParentAgentID *string `json:"parent_agent_id" gorm:"index"`
OwnerUserID string `json:"owner_user_id" gorm:"not null"`
Name string `json:"name" gorm:"not null"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Prompt *string `json:"prompt" gorm:"type:text"`
RepoURL *string `json:"repo_url"`
WorkflowID *string `json:"workflow_id"`
LlmModel string `json:"llm_model" gorm:"default:'sonnet'"`
LlmTemperature float64 `json:"llm_temperature" gorm:"default:0.7"`
LlmMaxTokens int32 `json:"llm_max_tokens" gorm:"default:4000"`
BotAccountName *string `json:"bot_account_name"`
ResourceOverrides *string `json:"resource_overrides"`
EnvironmentVariables *string `json:"environment_variables"`
Labels *string `json:"labels"`
Annotations *string `json:"annotations"`
CurrentSessionID *string `json:"current_session_id"`
}
```
### `Session`
```go
type Session struct {
api.Meta
AgentID string `json:"agent_id" gorm:"not null;index"`
TriggeredByUserID *string `json:"triggered_by_user_id"`
ParentSessionID *string `json:"parent_session_id" gorm:"index"`
Phase *string `json:"phase"`
StartTime *time.Time `json:"start_time"`
CompletionTime *time.Time `json:"completion_time"`
SdkSessionID *string `json:"sdk_session_id"`
SdkRestartCount *int32 `json:"sdk_restart_count"`
Conditions *string `json:"conditions"`
ReconciledRepos *string `json:"reconciled_repos"`
ReconciledWorkflow *string `json:"reconciled_workflow"`
KubeCrName *string `json:"kube_cr_name"`
KubeCrUID *string `json:"kube_cr_uid"`
KubeNamespace *string `json:"kube_namespace"`
}
```
### `SessionCheckIn`
`agent_id` is denormalized so the Blackboard snapshot queries `session_checkins` directly by `agent_id` — no join through `sessions`.
```go
type SessionCheckIn struct {
api.Meta
SessionID string `json:"session_id" gorm:"not null;index"`
AgentID string `json:"agent_id" gorm:"not null;index"`
Summary string `json:"summary"`
Branch string `json:"branch"`
Worktree string `json:"worktree"`
PR string `json:"pr"`
Phase string `json:"phase"`
TestCount *int `json:"test_count"`
Items []string `json:"items" gorm:"serializer:json"`
Questions []string `json:"questions" gorm:"serializer:json"`
Blockers []string `json:"blockers" gorm:"serializer:json"`
NextSteps string `json:"next_steps"`
}
```
### `ProjectDocument`
Project-scoped markdown pages, upserted by slug. Reserved slugs: `protocol` (coordination rules), `archive` (historical records).
```go
type ProjectDocument struct {
api.Meta
ProjectID string `json:"project_id" gorm:"not null;uniqueIndex:idx_project_slug"`
Slug string `json:"slug" gorm:"not null;uniqueIndex:idx_project_slug"`
Title string `json:"title"`
Content string `json:"content" gorm:"type:text"`
}
```
### `AgentMessage`
Agent-to-agent inbox. Distinct from `SessionMessage` (AG-UI runtime events). Messages persist across re-ignitions — the inbox belongs to the Agent, not the run. Either `SenderAgentID` or `SenderUserID` is set (not both); human users can send via the API.
```go
type AgentMessage struct {
api.Meta
RecipientAgentID string `json:"recipient_agent_id" gorm:"not null;index"`
SenderAgentID *string `json:"sender_agent_id"`
SenderUserID *string `json:"sender_user_id"`
SenderName string `json:"sender_name"`
Body string `json:"body" gorm:"type:text"`
Read bool `json:"read" gorm:"default:false"`
}
```
### `Role`
```go
type Role struct {
api.Meta
Name string `json:"name" gorm:"uniqueIndex;not null"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Permissions []string `json:"permissions" gorm:"serializer:json"`
BuiltIn bool `json:"built_in" gorm:"default:false"`
}
```
Permissions stored as `[]string` of canonical `"resource:action"` keys. Resolved to typed `Permission` structs in the service layer — no string matching at auth time.
### `RoleBinding`
```go
type RoleBinding struct {
api.Meta
UserID string `json:"user_id" gorm:"not null;index:idx_binding_lookup"`
RoleID string `json:"role_id" gorm:"not null;index:idx_binding_lookup"`
Scope string `json:"scope" gorm:"not null;index:idx_binding_lookup"`
ScopeID string `json:"scope_id" gorm:"index:idx_binding_lookup"`
}
```
`Scope` values: `"global"` | `"project"` | `"agent"` | `"session"`. `ScopeID` is empty for global bindings. Composite index on `(user_id, scope, scope_id)` makes the authorization hot path a single indexed query.
---
## RBAC
### Scopes
| Scope | Meaning |
|---|---|
| `global` | Applies across the entire platform |
| `project` | Applies to all agents and sessions in a project |
| `agent` | Applies to one agent and all its sessions |
| `session` | Applies to one session run only |
Effective permissions = union of all applicable bindings (global ∪ project ∪ agent ∪ session). No deny rules.
### Resources and Permissions
```go
package rbac
type Resource string
const (
ResourceUser Resource = "user"
ResourceProject Resource = "project"
ResourceProjectSettings Resource = "project_settings"
ResourceProjectDocument Resource = "project_document"
ResourceAgent Resource = "agent"
ResourceSession Resource = "session"
ResourceSessionMessage Resource = "session_message"
ResourceSessionCheckIn Resource = "session_checkin"
ResourceAgentMessage Resource = "agent_message"
ResourceBlackboard Resource = "blackboard"
ResourceRole Resource = "role"
ResourceRoleBinding Resource = "role_binding"
)
type Action string
const (
ActionCreate Action = "create"
ActionRead Action = "read"
ActionUpdate Action = "update"
ActionDelete Action = "delete"
ActionList Action = "list"
ActionWatch Action = "watch"
ActionIgnite Action = "ignite"
ActionCheckin Action = "checkin"
ActionMessage Action = "message"
)
type Permission struct {
Resource Resource
Action Action
}
func (p Permission) String() string {
return string(p.Resource) + ":" + string(p.Action)
}
var (
PermUserRead = Permission{ResourceUser, ActionRead}
PermUserList = Permission{ResourceUser, ActionList}
PermUserCreate = Permission{ResourceUser, ActionCreate}
PermUserUpdate = Permission{ResourceUser, ActionUpdate}
PermUserDelete = Permission{ResourceUser, ActionDelete}
PermProjectCreate = Permission{ResourceProject, ActionCreate}
PermProjectRead = Permission{ResourceProject, ActionRead}
PermProjectUpdate = Permission{ResourceProject, ActionUpdate}
PermProjectDelete = Permission{ResourceProject, ActionDelete}
PermProjectList = Permission{ResourceProject, ActionList}
PermProjectSettingsRead = Permission{ResourceProjectSettings, ActionRead}
PermProjectSettingsUpdate = Permission{ResourceProjectSettings, ActionUpdate}
PermProjectDocumentRead = Permission{ResourceProjectDocument, ActionRead}
PermProjectDocumentCreate = Permission{ResourceProjectDocument, ActionCreate}
PermProjectDocumentUpdate = Permission{ResourceProjectDocument, ActionUpdate}
PermProjectDocumentDelete = Permission{ResourceProjectDocument, ActionDelete}
PermProjectDocumentList = Permission{ResourceProjectDocument, ActionList}
PermAgentCreate = Permission{ResourceAgent, ActionCreate}
PermAgentRead = Permission{ResourceAgent, ActionRead}
PermAgentUpdate = Permission{ResourceAgent, ActionUpdate}
PermAgentDelete = Permission{ResourceAgent, ActionDelete}
PermAgentList = Permission{ResourceAgent, ActionList}
PermAgentIgnite = Permission{ResourceAgent, ActionIgnite}
PermSessionRead = Permission{ResourceSession, ActionRead}
PermSessionList = Permission{ResourceSession, ActionList}
PermSessionDelete = Permission{ResourceSession, ActionDelete}
PermSessionMessageWatch = Permission{ResourceSessionMessage, ActionWatch}
PermSessionCheckInCreate = Permission{ResourceSessionCheckIn, ActionCreate}
PermSessionCheckInRead = Permission{ResourceSessionCheckIn, ActionRead}
PermSessionCheckInList = Permission{ResourceSessionCheckIn, ActionList}
PermAgentMessageSend = Permission{ResourceAgentMessage, ActionMessage}
PermAgentMessageRead = Permission{ResourceAgentMessage, ActionRead}
PermAgentMessageDelete = Permission{ResourceAgentMessage, ActionDelete}
PermBlackboardWatch = Permission{ResourceBlackboard, ActionWatch}
PermBlackboardRead = Permission{ResourceBlackboard, ActionRead}
PermRoleRead = Permission{ResourceRole, ActionRead}
PermRoleList = Permission{ResourceRole, ActionList}
PermRoleCreate = Permission{ResourceRole, ActionCreate}
PermRoleUpdate = Permission{ResourceRole, ActionUpdate}
PermRoleDelete = Permission{ResourceRole, ActionDelete}
PermRoleBindingRead = Permission{ResourceRoleBinding, ActionRead}
PermRoleBindingList = Permission{ResourceRoleBinding, ActionList}
PermRoleBindingCreate = Permission{ResourceRoleBinding, ActionCreate}
PermRoleBindingDelete = Permission{ResourceRoleBinding, ActionDelete}
)
```
### Built-in Roles
Seeded at migration time. `built_in = true` prevents deletion.
```go
const (
RolePlatformAdmin = "platform:admin"
RolePlatformViewer = "platform:viewer"
RoleProjectOwner = "project:owner"
RoleProjectEditor = "project:editor"
RoleProjectViewer = "project:viewer"
RoleAgentOperator = "agent:operator"
RoleAgentObserver = "agent:observer"
RoleAgentRunner = "agent:runner"
)
```
### Permission Matrix
| Role | Projects | Agents | Sessions | Documents | Check-ins | Inbox | Blackboard | RBAC |
|---|---|---|---|---|---|---|---|---|
| `platform:admin` | full | full | full | full | full | full | full | full |
| `platform:viewer` | read/list | read/list | read/list | read/list | read/list | — | watch/read | read/list |
| `project:owner` | full | full | full | full | full | full | watch/read | project+agent bindings |
| `project:editor` | read | create/update/ignite | read/list | create/update | create/read | send/read | watch/read | — |
| `project:viewer` | read | read/list | read/list | read/list | read/list | — | watch/read | — |
| `agent:operator` | — | update/ignite | read/list | — | create/read | send/read | — | — |
| `agent:observer` | — | read | read/list | — | read/list | — | — | — |
| `agent:runner` | — | read | read | read | create | send | — | — |
### Authorization Flow
```
Request → JWT middleware → RBAC middleware
resolve user from token
fetch bindings:
global
+ project (resource's project)
+ agent (resource's agent)
+ session (resource's session)
union → effective permissions
check required Permission constant
allowed / 403
```
Each handler declares its required `Permission` as a constant. No string literals in authorization checks.
---
## API
### Agents
```
GET /api/ambient/v1/projects/{id}/agents list agents in project (tree)
GET /api/ambient/v1/agents/{id} read
POST /api/ambient/v1/agents create
PATCH /api/ambient/v1/agents/{id} update definition
DELETE /api/ambient/v1/agents/{id} delete
GET /api/ambient/v1/agents/{id}/sessions run history
GET /api/ambient/v1/agents/{id}/ignition ignition prompt (no session created)
POST /api/ambient/v1/agents/{id}/ignite create session + return prompt + session
GET /api/ambient/v1/agents/{id}/checkins full check-in history across all sessions
GET /api/ambient/v1/agents/{id}/inbox read inbox (unread first)
POST /api/ambient/v1/agents/{id}/inbox send message to this agent
PATCH /api/ambient/v1/agents/{id}/inbox/{msg_id} mark read
DELETE /api/ambient/v1/agents/{id}/inbox/{msg_id} delete
GET /api/ambient/v1/agents/{id}/role_bindings bindings scoped to this agent
```
`POST /agents/{id}/ignite` response body:
```json
{
"session": { "id": "...", "agent_id": "...", "phase": "pending", ... },
"ignition_prompt": "# Agent: API\n\nYou are API, working in project..."
}
```
Ignition prompt assembles: agent identity + definition, peer agent roster with latest check-ins, project protocol document, check-in POST template.
### Sessions
```
GET /api/ambient/v1/sessions/{id} read
DELETE /api/ambient/v1/sessions/{id} cancel/delete
GET /api/ambient/v1/sessions/{id}/messages SSE event stream (AG-UI)
POST /api/ambient/v1/sessions/{id}/checkin submit check-in
GET /api/ambient/v1/sessions/{id}/checkin latest check-in
GET /api/ambient/v1/sessions/{id}/checkins full check-in history
GET /api/ambient/v1/sessions/{id}/role_bindings bindings scoped to this session
```
### Project Documents
```
GET /api/ambient/v1/projects/{id}/documents list
GET /api/ambient/v1/projects/{id}/documents/{slug} read
PUT /api/ambient/v1/projects/{id}/documents/{slug} upsert
DELETE /api/ambient/v1/projects/{id}/documents/{slug} delete
```
### Blackboard
```
GET /api/ambient/v1/projects/{id}/blackboard SSE — streams check-in events for all agents in project
GET /api/ambient/v1/projects/{id}/blackboard/snapshot JSON — latest check-in per agent (dashboard bootstrap)
```
Snapshot query:
```sql
WITH latest_checkins AS (
SELECT DISTINCT ON (agent_id) *
FROM session_checkins
ORDER BY agent_id, created_at DESC
)
SELECT a.*, lc.*
FROM agents a
LEFT JOIN latest_checkins lc ON lc.agent_id = a.id
WHERE a.project_id = ?
ORDER BY a.name
```
### RBAC
```
GET /api/ambient/v1/roles
GET /api/ambient/v1/roles/{id}
POST /api/ambient/v1/roles
PATCH /api/ambient/v1/roles/{id}
DELETE /api/ambient/v1/roles/{id}
GET /api/ambient/v1/role_bindings
POST /api/ambient/v1/role_bindings
DELETE /api/ambient/v1/role_bindings/{id}
GET /api/ambient/v1/users/{id}/role_bindings
GET /api/ambient/v1/projects/{id}/role_bindings
GET /api/ambient/v1/agents/{id}/role_bindings
GET /api/ambient/v1/sessions/{id}/role_bindings
```
---
## Frontend: Blackboard View
Project-scoped fleet dashboard. Agents as rows, current check-in as columns, SSE-live.
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PROJECT: sdk-backend-replacement [Blackboard] │
├──────────────┬──────────┬──────────────┬────────┬─────────┬─────────────────────┤
│ Agent │ Status │ Branch │ PR │ Tests │ Summary │
├──────────────┼──────────┼──────────────┼────────┼─────────┼─────────────────────┤
│ Overlord │ 🟢 active│ docs/ocp.. │ — │ — │ Infrastructure.. │
│ ├─ API │ 🟢 active│ feat/sess.. │ — │ 25 │ Session messages.. │
│ ├─ FE │ 🟢 active│ feat/front..│ — │ — │ Frontend running.. │
│ └─ CP │ 🟢 active│ feat/grpc.. │ #815 │ 28 │ Runner gRPC AG-UI. │
│ └─ Reviewer │ 🟡 idle │ — │ — │ — │ Awaiting CP response │
├──────────────┴──────────┴──────────────┴────────┴─────────┴─────────────────────┤
│ [?] questions in amber [!] blockers in red │
│ [▶ Ignite] [✉ Message] [⊕ New Agent] [📄 Protocol] │
└──────────────────────────────────────────────────────────────────────────────────┘
```
- Tree rows: `parent_agent_id` hierarchy, collapsible
- SSE via `GET /projects/{id}/blackboard`
- Click row → agent detail: definition, run history, session event stream
- Ignite → `POST /agents/{id}/ignite`
- Message → `POST /agents/{id}/inbox`
- Protocol sidebar → `GET /projects/{id}/documents/protocol`
---
## Implementation Order
| # | Feature | Effort |
|---|---|---|
| 1 | `pkg/rbac` — Resource/Action/Permission constants package | Small |
| 2 | `Agent` plugin — model, CRUD, ignite, ignition prompt | Large |
| 3 | `Role` + `RoleBinding` plugins + built-in role seeding | Medium |
| 4 | RBAC middleware — DB-backed, wired after JWT middleware | Small |
| 5 | `Session` migration — add `agent_id`, remove identity fields | Medium |
| 6 | `SessionCheckIn` plugin — with `agent_id` denormalization | Medium |
| 7 | `ProjectDocument` plugin — upsert by slug | Small |
| 8 | Blackboard snapshot + SSE endpoints | Medium |
| 9 | `AgentMessage` plugin | Medium |
| 10 | Frontend Blackboard view | Large |
---
## Design Decisions
| Decision | Rationale |
|---|---|
| Agent is persistent, Session is ephemeral | Agent identity survives runs; execution state does not |
| `parent_agent_id` on Agent | Fleet hierarchies are first-class in the data model |
| `current_session_id` denormalized on Agent | Blackboard reads Agent + check-in without joining through sessions |
| `agent_id` denormalized on `SessionCheckIn` | Snapshot query is O(agents), not O(sessions × checkins) |
| `AgentMessage` inbox on Agent, not Session | Messages persist across re-ignitions |
| Sessions created only via ignite | Sessions are run artifacts; direct POST /sessions is removed |
| Four-scope RBAC | Agent scope enables sharing one agent without exposing the whole project |
| `agent:runner` role | Pods get the minimum viable credential: read agent, create check-ins, send messages |
| Permissions as Go constants | Every resource/action pair known at compile time — typo-proof, grep-able |
| Union-only permissions | No deny rules — simpler mental model for fleet operators |
| `ProjectDocument` upserted by slug | Pages are edited in-place; version history is future scope |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment