Skip to content

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_idDispatcherInstance.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_browser adds a Chromium (or Firefox) process to the container image so the agent runs a headful browser the operator can observe in real time.
  • For headless variants 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:

  1. Workload (or Dispatch Service) calls POST /api/dispatch/gates/ with { task_id, step_name, prompt }.
  2. Control plane creates HumanGate (status=PENDING) and fires registered notification hooks in order (Slack, email, webhook — configured per install).
  3. Approver clicks Approve / Reject in the Task control UI or via a deep-link in the notification.
  4. PATCH /api/dispatch/gates/{id}/ sets status + decided_by.
  5. 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