> ## Documentation Index
> Fetch the complete documentation index at: https://developers.reintersect.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Progress updates

> Stream a command's or message action's answer from an async generator or an Effect Stream, so its content, notice and block move through the stages of long work.

> Reintersect apps are in developer preview. Names and limits can still change before general
> release.

Some commands start work that takes a while and has stages a member cares about: a deploy goes from
queued to building to live, an import counts rows, a report runs its queries. Instead of answering
once, or deferring and answering at the end, your handler can stream its answer. The first answer is
posted as usual, and every later one replaces it in place: the chat message, the notice and the
block's props.

Stream when the stages are worth watching. For work that only has a result, [defer](/commands#deferring)
and answer once.

## Stream from an async generator

Make the handler of a [slash command](/commands) or [message action](/message-actions) an
`async function*`. Each `yield` is an answer, the same shape a handler returns. The generator's
`return` value, if it has one, is the last update.

```tsx src/deployCommand.tsx theme={null}
import { defineBlock, defineCommand } from "@reintersect/sdk/app";
import { Block, Bold, Header, Paragraph, Status } from "@reintersect/sdk/components";
import { z } from "zod";
import { waitForStage } from "./ci";

const STAGES = ["queued", "building", "deploying", "live"] as const;

export const Deploy = defineBlock({
  type: "deploy",
  title: "Deployment",
  props: z.object({ service: z.string(), status: z.enum(STAGES) }),
  state: z.object({}),
  initialState: {},
}).render(async (props) => (
  <Block summary={`${props.service} is ${props.status}`}>
    <Header trailing={<Status state="running" label={props.status} />}>{props.service}</Header>
  </Block>
));

export const DeployCommand = defineCommand(
  "deploy",
  {
    description: "Deploy a service",
    options: z.object({
      service: z.string().describe("The service to deploy"),
      env: z.enum(["staging", "production"]).describe("Where to deploy it"),
    }),
  },
  async function* ({ options }) {
    for (const status of STAGES) {
      await waitForStage(options.service, status);
      yield {
        visibility: "public",
        content: (
          <Paragraph>
            <Bold>{options.service}</Bold> is {status} on {options.env}.
          </Paragraph>
        ),
        block: Deploy.insert({ service: options.service, status }),
      };
    }
  },
);
```

The yields are typed like a handler's answer, so `Deploy.insert` checks its props and the app must
include `Deploy`. If the generator throws after its first answer, the SDK finishes the answer with
the failure notice, "Couldn't finish this. Try again.", and logs the error.

## Stream from an Effect

On the Effect API, return a `Stream` of answers. Services and failures in the stream become the
command's requirements and errors. See [Progress updates](/effect/commands-and-events#progress-updates)
in the Effect guide.

```ts theme={null}
({ options }) =>
  Stream.map(deploys.stages(options.service), (status) => ({
    visibility: "public" as const,
    block: Deploy.insert({ service: options.service, status }),
  }))
```

## How the answer moves

The SDK starts the generator or stream when the delivery arrives:

1. If the first answer comes within 3 seconds, it's the delivery's answer, the same as a normal
   handler's.
2. If it's slower, the SDK answers the delivery with `202` and your app's row reads "is thinking…"
   until the first answer arrives. Only the member who ran the command sees that row. Pass
   `progress: { visibility: "public" }` to the definition to show it to everyone in the circle or
   direct message.
3. Every later answer is sent as an update. The SDK waits 500ms after an answer arrives, then sends
   the latest one, so a generator that yields faster than that skips the stages in between.
4. When the generator returns or the stream ends within 500ms of its last value, the SDK sends that
   value once, as the final update. If the stream ends later, that value has already gone out as an
   update, so the SDK sends it again as the final update to close the answer. The answer can't
   change after that.

The SDK keeps running the work after the delivery returns, with `waitUntil`. Hosted runtimes keep
that work alive for a limited time, so hand work that runs for many minutes to a queue and update the
answer from there, as described in [Update from somewhere else](#update-from-somewhere-else).

## Blocks and visibility

The first answer decides the visibility. A later answer with `content` or a `block` repeats the same
`visibility`, and so does the generator's `return`; the types require it, and the SDK rejects one that
leaves it out. A later answer that's only a `notice` keeps the first answer's visibility. Changing it
is an error.

A later answer's `block` must come from the same block definition as the first answer's block. Its
props replace the block's props and the block renders again. Leave `block` out to keep the block as it
is. Switching to another block, or adding a block when the first answer had none, is an error.

When the stream breaks either rule, the SDK stops it, logs `ReintersectAppProgressMismatchError`
(a defect on the Effect API), and finishes the answer with the failure notice.

## Limits

| Limit | Value |
| - | - |
| Updates Reintersect accepts | One every 250ms. Faster updates get `429` with `retryAfterMs`. |
| Updates the SDK sends | One every 500ms, with the latest value. |
| Final update | Always accepted while the answer is open, even straight after a `429`. |
| Text and notice updates | Until 15 minutes after the last accepted update, and never more than 2 hours after the command ran. |
| Block updates | While your app sends one at least every 15 minutes, with no overall limit. |
| Slack and Discord mirrors | Edited at most once every 3 seconds, with the latest value. The final state is always mirrored. |

There's no limit on the number of updates.

When the 2 hours pass, or 15 minutes go by without an update, Reintersect stops taking text and notice
changes. If your stream keeps going, the SDK keeps sending its block changes on their own, so the
block stays current while your app reports at least every 15 minutes. These updates need no extra
scope, because the member's command created the block. The answer ends when the stream does.

## Errors

| Error | Status | When |
| - | - | - |
| `ReintersectAppInvocationUpdateRateLimitedError` | 429 | An update came less than 250ms after the last one. Carries `retryAfterMs`. The SDK waits that long and tries again. |
| `ReintersectAppInteractionPendingError` | 409 | Reintersect hasn't recorded the first answer yet, which can happen right after your app answers with a `200`. The SDK retries with backoff for up to about 10 seconds, and the update after it carries the newest value your stream yielded meanwhile. When you call the API yourself, retry the same way. |
| `ReintersectAppInteractionExpiredError` | 401 | The token is unknown or expired, or the answer failed. The SDK stops sending updates. |
| `ReintersectAppInvocationClosedError` | 409 | The answer is final, or its window has closed. |
| `ReintersectAppInvocationVisibilityMismatchError` | 422 | An update changed the answer's visibility. |
| `ReintersectAppInvocationBlockMismatchError` | 422 | An update named a different block. |
| `ReintersectAppProgressMismatchError` | | The SDK caught a visibility or block change before sending it. |

## Update from somewhere else

When a queue worker or another service does the work, defer the answer, pass
`invocation.interactionToken` along, and send updates with the app client. Answer first with
`invocations.respond`, then call `invocations.update`, with `final: true` on the last one:

```ts src/deployWorker.ts theme={null}
import { createReintersectClient } from "@reintersect/sdk";
import { app } from "./app";

export const reportStage = async (installId: string, interactionToken: string, status: string) => {
  const reintersect = createReintersectClient(app, { installId });

  return reintersect.invocations.update({
    interactionToken,
    answer: { visibility: "public", notice: `The deploy is ${status}` },
    final: status === "live",
  });
};
```

Without the SDK, send `POST /v0/invocations/update` with the install token and
`{ interactionToken, answer, final? }`.

## Testing

`host.runCommand` returns the first answer, or `{ status: "deferred" }` when the first answer took
longer than the 3-second budget on the test clock. While your handler waits on the test clock, such as
an `Effect.sleep`, the clock jumps ahead; while it waits on real work, such as a `fetch`, the clock
keeps pace with real time, so only work that really takes 3 seconds is deferred. `host.settle()`
runs the stream to its end and moves the test clock through the SDK's waits.
`host.answersOf(interactionToken)` returns every answer the member saw, in order, and whether the
answer is final. `host.expectProgression` checks that answers matching each step arrived in that
order, with any other answers in between, and that the answer finished. A stage that coalescing
skipped never reaches the member, so a step that names it fails, and the failure says which step
went missing. Name only the stages that last at least 500ms, such as the first answer and the final
one:

```ts theme={null}
const outcome = await host.runCommand("deploy", { service: "api", env: "staging" });

await host.settle();

await host.expectProgression(outcome.interactionToken, [
  (answer) => answer.block?.props.status === "queued",
  (answer) => answer.block?.props.status === "live",
]);
```

Each update is also an `invocations.update` push with its `final` flag.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.