Skip to content

Agent-Active Banner

The agent-active banner appears in the contact chat view whenever a non-terminal plan is running on the contact. It tells every user — even those without plan permissions — that an AI agent is handling the conversation.

When an autonomous plan sends messages on a contact thread, users without plan visibility see outbound messages appearing “from nowhere.” The banner eliminates that confusion by surfacing agent activity to anyone who can view the contact.

GET /api/contacts/{contact_id}/active-plans

Permission gate: ContactInstanceAction::View — not plan-level permissions. Any user who can see the contact gets the banner.

Response type: ContactActivePlansSummary

pub struct ContactActivePlansSummary {
pub plans: Vec<ContactActivePlan>,
pub can_view_plans: bool,
}
pub struct ContactActivePlan {
pub id: Uuid,
pub state: PlanState,
pub template_id: Option<Uuid>,
pub template_name: Option<String>,
}

can_view_plans reflects whether the caller holds PlanCollectionPermission::List. The UI uses this flag to decide whether to render “View plan →” links and template names.

For callers without plan permissions, template_id and template_name are None, and PlanState is redacted via redact_for_limited_view() — stripping payloads like Failed { error_message } to prevent leaking execution internals.

The use_contact_active_plans hook seeds from the API on mount, then subscribes to two WebSocket events:

EventEffect
plan.state.changedUpdates the plan’s state in the local list, or removes it if the new state is terminal
plan.createdAdds the plan to the list if it targets this contact and is non-terminal

On WebSocket reconnect, the hook refetches from the API to reconcile any missed transitions.

The hook mirrors the server-side permission gate locally — template_id and payload-carrying states are never written into the client signal for permission-less callers, even though WebSocket events broadcast org-wide.

The banner uses three visual variants based on aggregate plan state. The “loudest” state wins when multiple plans are active.

ToneTrigger statesDotBackground
ExecutingAny plan is ExecutingPulsing primaryPrimary tint + primary left rail
ActiveAny plan is StandBy (none executing)Static primaryPrimary tint + primary left rail
WaitingAll plans are Paused, AwaitingInput, or PendingReviewStatic amberAmber tint + amber left rail

Single-plan messages adapt to the current state:

StateMessage
ExecutingAn AI agent is working on this contact right now
StandByAn AI agent is scheduled to continue on this contact
PendingReviewAn AI agent is waiting for your approval to start
AwaitingInputAn AI agent needs your input to continue
PausedAn AI agent is paused on this contact

Multi-plan: “Multiple AI agents are handling this contact (N active)”

Permission-less fallback: “An AI agent is handling this contact automatically” — no state-specific wording, no template names.

ElementWith plan permissionWithout plan permission
Banner visibility
State-specific wordingGeneric fallback
Template name (clickable link)Hidden
”View plan →” linkHidden
FilePurpose
plan/api/get_contact_active_plans_api.rsAPI endpoint, permission gating, state redaction
plan/hooks/use_contact_active_plans.rsReal-time hook with WebSocket subscription
plan/components/contact_plan_active_banner_component.rsBanner UI with tone logic and message rendering
plan/types/contact_active_plans_summary_type.rsContactActivePlansSummary and ContactActivePlan types
plan/types/plan_state_type.rsPlanState::is_active(), redact_for_limited_view(), from_db_value()

Three methods were added to PlanState for this feature:

// Returns true for non-terminal states (not Completed, Failed, or Stopped)
pub fn is_active(&self) -> bool
// Strips sensitive payloads for permission-less callers
// Uses exhaustive match — adding a new variant forces a redaction decision
pub fn redact_for_limited_view(self) -> Self
// Server-only: parses JSONB from DB, logs warnings on failure
pub fn from_db_value(plan_id: Uuid, value: Value) -> Option<Self>