# Steer composer

Send a message into a run while it works, and see whether the agent has read it. Each message says where it is: sending, sent but not picked up yet, picked up (and when), or not delivered with the reason and a Retry. A message isn't shown as read until the service says so. The note above the box says when the agent reads messages, or that the run is quiet, offline or waiting on the person (a message doesn't answer its question). A finished run takes no messages and says so. Enter sends and Shift+Enter breaks a line.

- Registry name: `steer-composer` (`@candor/steer-composer`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/steer-composer

## Install

```bash
npx candor-ui add steer-composer
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/steer-composer`.
It brings in: button, locale, relative-time, run-heartbeat, status, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import type { SteerMessage } from "@/components/steer-composer"
import { SteerComposer } from "@/components/steer-composer"

const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString()

const initial: SteerMessage[] = [
  { id: "m1", text: "Skip the archived orders; they’re read-only.", sentAt: minutesAgo(6), state: "picked-up", pickedUpAt: minutesAgo(5) },
  { id: "m2", text: "Use the EU region for the new column’s default.", sentAt: minutesAgo(1), state: "queued" },
]

export default function Example() {
  const [messages, setMessages] = React.useState(initial)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  React.useEffect(() => () => timers.current.forEach(clearTimeout), [])
  const move = (id: string, patch: Partial<SteerMessage>, after: number) =>
    timers.current.push(setTimeout(() => setMessages((ms) => ms.map((m) => (m.id === id ? { ...m, ...patch } : m))), after))

  return (
    <SteerComposer
      className="max-w-md"
      agentName="Build agent"
      run={{ status: "running", lastHeardAt: minutesAgo(0.5) }}
      messages={messages}
      onSend={(text) => {
        const id = String(Date.now())
        setMessages((ms) => [...ms, { id, text, sentAt: new Date().toISOString(), state: "sending" }])
        // Your service says when it has the message, and again when the agent reads it.
        move(id, { state: "queued" }, 600)
        move(id, { state: "picked-up", pickedUpAt: new Date().toISOString() }, 4000)
      }}
      onWithdraw={(id) => setMessages((ms) => ms.filter((m) => m.id !== id))}
    />
  )
}
```

## <SteerComposer>

Props:

- `run: { status: RunStatus; lastHeardAt?: Date | string | undefined; hostOffline?: HostOffline | undefined }`: The run being steered: its status, when it was last heard from, and whether its host is offline.
- `messages: SteerMessage[]`: Messages already sent, oldest first, each with where it is.
- `onSend: (text: string) => void`: Called with the person's words. Add the message to `messages` (as "sending" or "queued") to show it.
- `onRetry?: ((id: string) => void) | undefined`: Called with a failed message's id when the person picks Retry. Without it, there's no Retry.
- `onWithdraw?: ((id: string) => void) | undefined`: Called with a queued message's id to take it back before the agent reads it. Without it, there's no Withdraw.
- `agentName?: string | undefined`: The agent's name, from your app, used in "Build agent hasn't picked it up yet".
- `pickupNote?: string | null | undefined`: When your agent reads new messages, in your words. Defaults to "between steps"; null hides it.
- `staleAfter?: number | undefined` (default `STALE_AFTER`): How long a running run may be silent before it's called quiet, in milliseconds. Defaults to two minutes.
- `placeholder?: string | undefined`
- `className?: string | undefined`

## Strings (`steerComposer`)

The fixed words, English by default. Override any of them under `steerComposer` in `<LocaleProvider strings={{ steerComposer: { … } }}>`; the table is exported as `steerComposerStrings`.

- `label`: Message to the run
- `messages`: Messages to the run
- `placeholder`: Add a note or change direction
- `send`: Send
- `sending`: Sending…
- `queued`: Sent. The agent hasn’t picked it up yet.
- `queuedBy`: Sent. {agent} hasn’t picked it up yet.
- `pickedUp`: Picked up
- `pickedUpBy`: Picked up by {agent}
- `failed`: Not delivered
- `failedWhy`: Not delivered: {reason}
- `retry`: Retry
- `retryLabel`: Retry “{text}”
- `withdraw`: Withdraw
- `withdrawLabel`: Withdraw “{text}”
- `pickupNote`: The agent reads new messages between steps, so it may finish what it’s doing first.
- `waitingNote`: The run is waiting on you. A message here doesn’t answer what it asked.
- `staleNote`: {status}, so it may not read this soon.
- `offlineNote`: {status}. Messages wait until it’s back.
- `ended`: This run has ended, so it can’t take messages.
- `hintSend`: Enter to send, Shift+Enter for a new line

## Types

### SteerState

Where a message to a running run is. "sending": on its way to your service. "queued": your service has it, and the agent hasn't read it yet. "picked-up": the agent has read it (at `pickedUpAt`). "failed": it wasn't delivered (`reason` says why).

```ts
"sending" | "queued" | "picked-up" | "failed"
```

### SteerMessage

One message sent into a run while it works.

```ts
{ id: string /** The person's words, shown as plain text. */ text: string sentAt: Date | string state: SteerState /** When the agent read it, when the service reports that. */ pickedUpAt?: Date | string | undefined /** Why it wasn't delivered. Shown with "failed". */ reason?: string | undefined }
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
