Architecture: Agent Dispatch Layer¶
The agent dispatch layer is implemented as three optional Django apps. Teams that don't
need agent capabilities carry none of the models, APIs, or UI. Each app is toggled via
INSTALLED_APPS in the install config — presence in INSTALLED_APPS is the gate, no
runtime feature-flag checks in code.
Related issues: #39 (epic), #40, #41, #42, #43, #44, #46, #47.
App map¶
astrolift (core)
└── astrolift_agents (optional — what an agent IS)
└── astrolift_agent_skills (optional — what an agent KNOWS)
└── astrolift_agent_dispatch (optional — where an agent RUNS)
Each app can be installed independently up to its dependency chain.
Installing astrolift_agent_dispatch without astrolift_agent_skills is a
startup error (validated in AppConfig.ready()).
astrolift_agents — what an agent IS¶
Adds agent-flavored workload semantics on top of the core Workload model. No
extra dependencies — it can stand alone.
Enum extensions on Workload:
| Field | Values |
|---|---|
agent_variant |
headless | terminal_novnc | terminal_novnc_browser |
agent_runtime |
claude | gemini | codex | universal |
Data model sketch:
Workload (core)
agent_variant CharField(choices=AgentVariant, null=True, blank=True)
agent_runtime CharField(choices=AgentRuntime, null=True, blank=True)
Both fields are nullable so every existing Workload row is unaffected when the app is installed.
UI: Agent gallery — operator view of currently-running agent tasks, noVNC
embeds for terminal_novnc and terminal_novnc_browser variants.
GraphQL extensions (registered in AppConfig.ready()):
extend type Workload {
agentVariant: AgentVariantEnum
agentRuntime: AgentRuntimeEnum
}
enum AgentVariantEnum { HEADLESS TERMINAL_NOVNC TERMINAL_NOVNC_BROWSER }
enum AgentRuntimeEnum { CLAUDE GEMINI CODEX UNIVERSAL }
astrolift_agent_skills — what an agent KNOWS¶
Stores reusable instruction packages (Skill), callable tool definitions
(ToolDef), and assembled context bundles (Brief). No dependency on
astrolift_agents — the Workbench or other consumers can use Skills
independently.
Data model sketch:
Skill
id UUID PK
org FK → Organization (tenant-scoped)
name CharField
version CharField # semver, operator-assigned
content_hash CharField # SHA-256 of canonical JSON
instructions TextField # free-form markdown
scripts JSONField # {name: str, lang: str, body: str}[]
deps JSONField # pip/npm/etc package list
deleted_at DateTimeField null # soft delete
ToolDef
id UUID PK
org FK → Organization
name CharField
input_schema JSONField # JSON Schema for the tool's input
handler_ref CharField # dotted-path or container entrypoint
adapter CharField # "mcp" | "openai_function" | "raw"
deleted_at DateTimeField null
Brief
id UUID PK
org FK → Organization
content_hash CharField # SHA-256 of assembled payload
storage_key CharField # object-storage path (S3/GCS/ADLS key)
config JSONField # static key/value config block
skill_refs ManyToManyField → Skill
secret_refs JSONField # [{secret_name: str, inject_as: str}]
created_at DateTimeField auto
Brief is content-addressed: identical inputs produce the same content_hash,
and the control plane deduplicates object-storage writes by hash.
GraphQL extensions:
# Queries
skills(orgId: ID!): [Skill!]!
toolDefs(orgId: ID!): [ToolDef!]!
# Mutations (all return MutationResult)
createSkill(input: SkillInput!): MutationResult
updateSkill(id: ID!, input: SkillInput!): MutationResult
deleteSkill(id: ID!): MutationResult
createToolDef(input: ToolDefInput!): MutationResult
updateToolDef(id: ID!, input: ToolDefInput!): MutationResult
deleteToolDef(id: ID!): MutationResult
assembleBrief(input: BriefInput!): MutationResult # returns Brief id + storage_key
UI: Skill builder — create and edit Skills and ToolDefs without writing code.
astrolift_agent_dispatch — where an agent RUNS¶
Manages the registry of remote Dispatch Services (DispatcherInstance) and the
lifecycle of dispatch units (Task). Depends on both astrolift_agents and
astrolift_agent_skills.
DispatcherInstance placement (open question — Option A selected):
DispatcherInstance lives in astrolift_clusters (core) alongside
TenantCluster. Dispatch infrastructure is treated as a platform primitive, not
an agent-only concept. astrolift_agent_dispatch adds only Task and the
routing/UI layer.
Data model sketch:
DispatcherInstance (lives in astrolift_clusters / core)
id UUID PK
cluster FK → TenantCluster
service_url CharField # base URL of the Dispatch Service
capabilities JSONField # {label: str, value: str}[] — routing labels
last_heartbeat DateTimeField null
registered_at DateTimeField auto
deleted_at DateTimeField null
Task (lives in astrolift_agent_dispatch)
id UUID PK
org FK → Organization
brief FK → Brief
workload FK → Workload null # set once provisioning starts
dispatcher FK → DispatcherInstance null # set after routing
status CharField(choices=TaskStatus)
# DRAFT → QUEUED → PROVISIONING → RUNNING
# → SUCCEEDED | FAILED | CANCELLED
callback_url CharField null # push-mode completion notification
result JSONField null # terminal payload from Dispatch Service
created_at DateTimeField auto
started_at DateTimeField null
finished_at DateTimeField null
deleted_at DateTimeField null
GraphQL extensions:
# Queries
tasks(orgId: ID!, status: TaskStatusEnum): [Task!]!
task(id: ID!): Task
# Mutations
launchTask(input: LaunchTaskInput!): MutationResult
cancelTask(id: ID!): MutationResult
# Subscriptions (log streaming)
taskLogStream(taskId: ID!): TaskLogLine!
UI: Task control panel — launch, cancel, live status, and log stream viewer.
Interaction flow¶
1. REGISTRATION
Dispatch Service boots in a cluster.
POST /api/dispatch/register/ { service_url, capabilities, heartbeat_interval }
→ DispatcherInstance row created / updated in astrolift_clusters.
2. BRIEF CREATION
Operator or CI assembles a Brief via assembleBrief mutation.
Control plane: deduplicate by content_hash → upload payload to object storage
→ return Brief.id + storage_key.
3. TASK LAUNCH
Client calls launchTask(briefId, callbackUrl?) mutation.
Control plane:
a. Create Task (status=QUEUED).
b. Capability-based routing: match Task's required labels to live
DispatcherInstance.capabilities.
c. POST Brief download URL + Task metadata to selected Dispatch Service.
d. Dispatch Service ACKs → Task status=PROVISIONING.
4. AGENT RUN
Dispatch Service provisions the workload (spins a pod/container).
Workload runs the brief: loads instructions, injects secrets, exposes tools.
Status transitions → RUNNING once the container passes its health check.
Control plane proxies live log lines via WebSocket to the UI/subscribers.
5. HUMAN GATE (optional)
Any step can pause at a human-approval checkpoint.
See "Human gate notification hooks" section below.
6. COMPLETION
Push mode: Dispatch Service POSTs result to Task.callback_url.
Pull mode: Client polls GET /api/dispatch/tasks/{id}/status/.
Either path → Task status transitions to SUCCEEDED or FAILED.
Control plane fires completion hooks (webhooks, audit events).
Sequence diagram¶
sequenceDiagram
participant Client
participant ControlPlane as Control Plane
participant ObjStore as Object Storage
participant DispatchSvc as Dispatch Service
participant Workload
DispatchSvc->>ControlPlane: POST /api/dispatch/register/
ControlPlane-->>DispatchSvc: 200 OK (DispatcherInstance id)
Client->>ControlPlane: assembleBrief(...)
ControlPlane->>ObjStore: PUT brief payload (keyed by content_hash)
ControlPlane-->>Client: Brief { id, storage_key }
Client->>ControlPlane: launchTask(briefId, callbackUrl?)
ControlPlane->>ControlPlane: route to DispatcherInstance (capability match)
ControlPlane->>DispatchSvc: POST { task_id, brief_url, callback_url }
DispatchSvc-->>ControlPlane: 202 Accepted
DispatchSvc->>ObjStore: GET brief payload
DispatchSvc->>Workload: provision + start
Workload-->>DispatchSvc: health check pass
DispatchSvc->>ControlPlane: PATCH /api/dispatch/tasks/{id}/ { status: RUNNING }
Workload->>DispatchSvc: stream logs
DispatchSvc->>ControlPlane: WebSocket log relay
Workload-->>DispatchSvc: exit (success | failure)
DispatchSvc->>ControlPlane: POST callback_url { status, result }
ControlPlane->>ControlPlane: Task → SUCCEEDED | FAILED, fire hooks
noVNC / VNC proxy architecture¶
Applies to agent_variant = terminal_novnc and terminal_novnc_browser.
Browser
│ HTTPS (wss://)
▼
Astrolift control plane (websockify proxy, path: /novnc/{task_id}/)
│ VNC over WebSocket
▼
Dispatch Service (per-cluster sidecar or reverse proxy)
│ plain VNC (TCP 5900)
▼
Agent workload container (Xvfb + x11vnc)
- The control plane acts as a WebSocket-to-VNC proxy. It resolves
task_id→DispatcherInstance.service_url, then forwards the connection. Browser-side noVNC JS lib handles rendering. - The proxy path is authenticated by session cookie / API token. The underlying VNC port is never exposed to the public internet.
terminal_novnc_browseradds a Chromium (or Firefox) process to the container image so the agent runs a headful browser the operator can observe in real time.- For
headlessvariants the proxy is not started. The task UI shows only the log stream.
Human gate notification hooks¶
Any task step can emit a human_gate event to pause execution and wait for
approval. The hook system is intentionally thin — Astrolift fires the event and
stores the gate record; the notification channel is pluggable.
HumanGate (model, in astrolift_agent_dispatch)
id UUID PK
task FK → Task
step_name CharField
prompt TextField # question / context shown to the approver
status CharField # PENDING → APPROVED | REJECTED
decided_by FK → User null
decided_at DateTimeField null
created_at DateTimeField auto
Notification flow:
- Workload (or Dispatch Service) calls
POST /api/dispatch/gates/with{ task_id, step_name, prompt }. - Control plane creates
HumanGate(status=PENDING) and fires registered notification hooks in order (Slack, email, webhook — configured per install). - Approver clicks Approve / Reject in the Task control UI or via a deep-link in the notification.
PATCH /api/dispatch/gates/{id}/sets status + decided_by.- Control plane notifies the Dispatch Service (long-poll or WebSocket); the workload resumes or aborts.
Hooks are registered as a list in the install config:
ASTROLIFT_HUMAN_GATE_HOOKS = [
"astrolift_agent_dispatch.hooks.slack.SlackGateHook",
"astrolift_agent_dispatch.hooks.email.EmailGateHook",
# custom hook class — implement .notify(gate: HumanGate) -> None
]
Optional installation¶
INSTALLED_APPS = [
# core — always present
"astrolift_registry",
"astrolift_clusters", # DispatcherInstance lives here
"astrolift_lifecycle",
# ...
# optional — add to enable agent layer
"astrolift_agents", # agent_variant / agent_runtime on Workload
"astrolift_agent_skills", # Skill, ToolDef, Brief
"astrolift_agent_dispatch", # Task, HumanGate, routing, task UI
]
Startup validation (astrolift_agent_dispatch.apps.AgentDispatchConfig.ready()):
def ready(self):
from django.apps import apps
required = ["astrolift_agents", "astrolift_agent_skills"]
missing = [a for a in required if not apps.is_installed(a)]
if missing:
raise ImproperlyConfigured(
f"astrolift_agent_dispatch requires: {', '.join(missing)}"
)
GraphQL schema extensions from each app are registered in that app's
AppConfig.ready() via the Strawberry schema merge pattern. No dispatch
types bleed into the core schema when the app is absent.
Summary¶
| App | Adds | Depends on |
|---|---|---|
astrolift_agents |
AgentVariant, AgentRuntime on Workload; agent gallery UI; noVNC proxy |
nothing |
astrolift_agent_skills |
Skill, ToolDef, Brief; skill builder UI; Brief assembly mutation |
nothing |
astrolift_agent_dispatch |
Task, HumanGate, routing, task control UI, completion hooks |
astrolift_agents + astrolift_agent_skills |
astrolift_clusters (core) |
DispatcherInstance |
core |