Skip to main content

Ephemeral agents — hire, ghost, rehire

A Crewship crew can have permanent agents (the default — once created, they stay until soft-deleted) and ephemeral agents (hired with a TTL, ghost when the TTL elapses, rehirable if needed). The ephemeral lifecycle is for short-lived contractor work where you want a real audit trail of WHEN the agent was active without paying for it to sit in your roster forever. This page is the operator playbook for the four-state lifecycle: hire → live → ghost → rehire (or stay ghosted).

The states

The state machine derives from two columns rather than a single lifecycle enum because we wanted the schema to be queryable without an enum dance — WHERE ephemeral=1 AND expired_at IS NULL is the canonical “live ephemerals” query and a single integer comparison is faster than any enum string check. The trade-off (the state is reconstructed in the query layer) is documented as drift §10.5 in PRD-AGENT-EVOLUTION-2026.md.

Hiring

The response depends on the crew’s autonomy_level:
  • strict403 Forbidden — ephemeral hire is rejected outright. Operator changes the crew to guided or higher before hiring.
  • guided202 Accepted + a blocking inbox item. Agent row is created with status='PENDING_REVIEW'. Chatbridge will refuse to start the agent until operator approves the hire via the inbox.
  • trusted201 Created + a non-blocking inbox notification. Agent is live immediately.
  • full201 Created + journal-only logging.
See Autonomy + self-learning for the autonomy-level matrix.

Approving a pending-review hire

A guided-crew hire lands as PENDING_REVIEW. The inbox shows the hire request with an Approve hire button. Clicking it calls POST /api/v1/agents/{agentId}/approve-hire which:
  1. Wins the pending kind=ephemeral_hire row in the approvals queue — the single decision point shared with POST /api/v1/approvals/{id}/decide. If that row is already terminal (denied / timeout) it belongs to an earlier hire cycle, so it is left as the historical record and the agent row below becomes the decision point
  2. Flips agents.status from PENDING_REVIEW to IDLE (conditional UPDATE so a concurrent decision / TTL ghosting returns 409 instead of silently writing nothing)
  3. Resolves the blocking inbox waitpoint
  4. Commits — then writes the agent.hire_approved journal + audit entry
  5. Broadcasts an agent.hire_approved WebSocket event so the UI repaints the agent card from the PENDING_REVIEW chip to a normal status badge
Steps 1–3 run in one transaction. If any of them fails the whole approval rolls back: the queue row stays pending, the agent stays PENDING_REVIEW, and the waitpoint stays in the inbox, so a retry (or a decision through the approvals queue) still works. Journal, audit and WebSocket events are emitted only after the commit, so a dashboard never sees a transition that was rolled back. Losing the race with a concurrent decision returns 409; a failed inbox projection returns 500 with nothing applied.

Recovering a denied or lapsed hire

A denied hire ghosts the staged agent (expired_at set, status stays PENDING_REVIEW). An approval that nobody answers before its timeout ghosts it the same way: the sweeper flips the queue row to timeout and writes agents.expired_at in the same transaction, so a lapsed approval window bars hire approve (409) instead of leaving the hire quietly approvable days later. Both are recoverable with the normal ghost path:
rehire clears expired_at and pushes expires_at forward — that, not the approvals-queue row, is what marks the hire decidable again. The old denied / timeout row stays terminal because it is an accurate record of the previous cycle; the new approval is recorded as an agent.hire_approved audit entry alongside the agent.rehired one. A hire that has genuinely been decided in the current cycle — denied, or timed out of its approval window — still returns 409: both verdicts write agents.expired_at in the same transaction as the queue row they settle, and the approve’s conditional UPDATE … WHERE expired_at IS NULL is what rejects it. The blocking waitpoint stays in the inbox after a timeout — the hire still needs an operator call, it just needs a rehire first. rehire also works before the window lapses: it pushes expires_at forward, and the sweeper ghosts a staged hire only when the agent’s own deadline has passed. The queue row still flips to timeout on its original deadline (an accurate record of that cycle), but the extended hire stays approvable — hire approve returns 200 with no second rehire needed. The operator can also “fire” the pending agent by deleting it from the crew page — same path as deleting any agent. No separate “deny hire” endpoint exists today (tracked as PR-F follow-up); fire is the explicit reject path.

Credential setup

Ephemeral agents inherit a workspace Anthropic credential on hire — Crewship auto-assigns the first available one (API_KEY / AI_CLI_TOKEN, ordered by creation date) right after the agent row is committed, so the agent can authenticate on its first chat. If the workspace has no Anthropic credential yet, the hire still succeeds but the agent journals credential.auto_assign_empty and won’t be able to chat until one exists. Add one (Settings → Credentials, or crewship credential add), then rehire to trigger assignment — or assign manually with crewship credential assign <agent> <credential>. See the Credentials guide.

The live state

A live ephemeral agent is functionally identical to a permanent agent — same chat surface, same memory tools, same skills. The differences:
  • It carries ephemeral=1 so the agent card UI shows a small TTL badge with the remaining time
  • It counts against crews.max_ephemeral_agents (default 10 — configurable per crew via the policy panel)
  • A background sweeper checks every 5 minutes whether expires_at < now() and ghosts it if so
The quota matters because Crewship runs each agent in its own container — letting an operator hire 1000 ephemerals in a panic would exhaust host memory. When the quota is reached, hire returns 429 Too Many Requests with the live count + max in the response body, and the operator either raises the cap for that crew or rehires a ghost rather than hiring fresh (rehires don’t count — they reuse the existing row). The cap is the crews.max_ephemeral_agents column (0–100); set it through the crew policy panel or via PATCH /api/v1/crews/{crewId} with {"max_ephemeral_agents": N} — there is no dedicated CLI flag for it today.

Ghosting

The internal/ephemeral/expiry.go sweeper runs on a 5-minute ticker (default, configurable via DefaultSweepInterval):
  1. SELECT id, crew_id, workspace_id FROM agents WHERE ephemeral=1 AND expired_at IS NULL AND deleted_at IS NULL AND expires_at < now() AND status != 'RUNNING'
  2. For each matched row: UPDATE agents SET expired_at = now() WHERE id = ? (single-row UPDATE with the same WHERE guard so a concurrent rehire doesn’t double-flip)
  3. Emit agent.expired WebSocket event on the workspace hub
  4. Append a journal entry per ghosting with the agent_id + crew_id + reason=“ttl_elapsed”

TTL mid-mission grace

The sweeper deliberately skips agents with status='RUNNING' — a mission in flight gets to finish even if its TTL elapsed mid-call. The agent ghosts on the NEXT sweep after it idles back to IDLE. This avoids the worst-case “agent was in the middle of a tool call, TTL elapsed, ghost flag set, agent’s next response references a column that’s gone” scenario. The trade-off is that a long-running ephemeral can outlive its TTL by up to one sweep interval. Operators who need stricter timing should provision a permanent agent and explicitly soft-delete it.

Ghost UI affordance

A ghost agent appears in the agent canvas card list with:
  • Opacity 60% + grayscale 40% styling so it visually recedes from live agents
  • A Ghost status badge (slate background, ghost icon) replacing the usual IDLE / RUNNING chip
  • A hover-revealed Rehire button (top-right of the card on hover / focus-within) that opens the rehire dialog
Ghosts are sorted to the bottom of the agent list (live agents first by created_at DESC, then ghosts by expired_at DESC — most-recently-ghosted ghost ranks above older ghosts). This matters because crews accumulate ghosts over time and the operator’s eye should land on the live agents first.

Rehiring

Effect on the DB row:
  • expires_at = now() + ttl
  • expired_at = NULL (un-ghost)
  • hire_reason += new line with timestamp + reason
The row’s id, crew_id, slug, created_at, and everything else stay unchanged — the agent’s memory files (AGENT.md, PERSONA.md, lessons.md) are preserved across the ghost gap. A rehired agent picks up exactly where it left off, with full continuity.

Quota on rehire

Rehiring a ghost does NOT count against the quota (the ghost row already exists; rehire just toggles expired_at to NULL). Rehiring a live ephemeral is free for the same reason. Hiring a fresh ephemeral when the quota is full returns 429 — operator either raises the quota or rehires a ghost rather than hiring fresh. The rehire endpoint includes the same policy gate as hire (strict rejects, guided returns 202 with PENDING_REVIEW, etc.). Re-promoting a strict-crew ghost back to live still requires operator approval.

LEAD-driven hire from inside a container

A LEAD-mode agent in active orchestration can hire a sub-agent on demand by POSTing to the sidecar’s /spawn endpoint:
The sidecar proxies the request to POST /api/v1/internal/agents/hire, which:
  • Injects the MANAGER role into the request context (LEAD agents always hire as MANAGER, regardless of the LEAD’s own role)
  • Routes through the same policy gate as the public hire endpoint (a strict crew rejects LEAD-driven hires too — autonomy_level is the security boundary, not RBAC)
  • Returns 201 / 202 / 403 / 429 with the same shape as the public endpoint
The sidecar URL-encodes workspace_id before forwarding so reserved characters in operator-set workspace identifiers can’t poison the query downstream. See internal/sidecar/spawn.go.

Sub-agent briefing — pass a curated context slice

LEAD-driven hires often need to hand the sub-agent specific context — the mission so far, which files matter, what the LEAD has already tried. Rather than pass the LEAD’s full conversation history (noisy + leaks every internal monologue), Crewship provides the AgentBrief primitive (internal/orchestrator/agent_brief.go):
The brief lands on disk as .memory/BRIEF.md in the sub-agent’s container, and buildAgentMemoryBlock prepends it to the [AGENT MEMORY] section before the sub-agent’s first turn. The sub-agent sees the parent’s mission + curated memory references + constraints; it does NOT see the parent’s full chat history. Validation caps (defensive, not policy):
  • Mission ≤ 500 characters
  • SharedMemory ≤ 10 refs
  • Constraints ≤ 20 lines
Briefs are idempotent — re-applying overwrites in place; the sub-agent always sees the latest brief on the next system-prompt assembly.

Per-crew quota

crews.max_ephemeral_agents (default 10, range 0–100). UI input lives in the same Autonomy & behavior panel as the policy controls — placed there because the quota is logically a governance knob (it caps how many ephemeral spawns the agent fleet can issue before the operator notices). Setting max=0 blocks all ephemeral hires for the crew (use this as a per-crew kill switch — strict-mode rejection is per-hire, max=0 is per-crew).

Common workflows

”Incident response with a 4-hour hard stop"

"Burst of background analyzers during a release week”

Set the crew’s max_ephemeral_agents to 20 for the release window, hire one ephemeral per service the analyzer should cover, let them all ghost on TTL elapse. After the release, lower max_ephemeral_agents back to 10.

”Ghost as cheap reference”

Ghosting is free — the agent row stays in the DB. Operators sometimes deliberately let agents ghost rather than soft-delete them, so the agent’s hire_reason history + memory files are queryable later. A future SAR (“show me every agent that mentioned my email”) then has data to scan. Ghosts only become inert; they don’t disappear.

Cross-references