> ## 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.

# Writing to circles

> Post into a circle by use case: event feeds such as CI runs, deploys and alerts go to your app's stream, and conversational writes become chat messages and replies.

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

An installed app writes to a circle in one of two ways. Pick by what you're posting:

| You're posting | Use | Where it lands |
| - | - | - |
| A feed of events: CI runs, deploys, alerts, new tickets | [`reintersect.streams.sendEvent`](#app-stream-events) | Your app's stream in the circle. Each event becomes an app conversation, or a reply in one, and stays in the circle's App streams log until a member engages with it. |
| Something said in a conversation: an answer, a status reply, a block for people to act on | [`reintersect.replies.create`](#replies-and-blocks) and [`reintersect.blocks.insert`](#replies-and-blocks) | A message from your app in the circle's chat, a conversation, or a reply thread, where members see it right away. |

Answers to [slash commands](/commands) and [message actions](/message-actions) are conversational
writes too; Reintersect places them for you.

Both kinds of write follow your app's [reach](/concepts/install-and-consent#reach): public circles,
private circles that invited your app, and never circles connected to another workspace or a Slack
channel.

## App stream events

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

interface FinishedDeploy {
  readonly installId: string;
  readonly circleId: string;
  readonly service: string;
  readonly duration: string;
  readonly logsUrl: string;
}

export const reportDeploy = async (deploy: FinishedDeploy) => {
  const reintersect = createReintersectClient(app, { installId: deploy.installId });

  return reintersect.streams.sendEvent({
    circleId: deploy.circleId,
    threadKey: `deploy:${deploy.service}`,
    title: `Deploy ${deploy.service}`,
    markdown: `**${deploy.service}** finished in ${deploy.duration}. [Logs](${deploy.logsUrl})`,
  });
};
```

Inside handlers, use the context's `reintersect`, or `reintersect.forInstall(installId)` in a
[route](/hosting/routes).

Your app gets one stream in each circle it sends events to, named after the app and shown with its
icon. Nobody creates it by hand: it appears with your app's first event in that circle. Sending
needs the `replies:write` scope.

| Field | Type | Required | Description |
| - | - | - | - |
| `circleId` | `string` | Yes | The circle whose App streams log receives the event. |
| `content` | JSX or a document | One of | The event body. |
| `markdown` | `string` | One of | The event body as Markdown, 1 to 40,000 characters. |
| `threadKey` | `string` | No | Groups events about one object, at most 200 characters. The first event with a key starts a conversation; later events with the same key become replies in it. Without a key, every event starts a new conversation. |
| `title` | `string` | No | The conversation title, at most 300 characters. Used only when the event starts a conversation; without it, the first line of the content is used. |
| `persona` | `{ name?, avatarUrl? }` | No | Overrides your app's name (at most 80 characters) and icon (an `https://` URL) for this event, such as the CI system that produced it. |
| `idempotencyKey` | `string` | No | At most 200 characters. Reintersect remembers an accepted key for 24 hours and answers a resend with the original response instead of posting twice. |

Send exactly one of `content` and `markdown`. Events can't upload files or mention people: the SDK
rejects `Image`, `File` and `Mention` content before sending, with `ReintersectSdkInvalidInputError`.
Link to the resource instead.

The answer is `{ conversationId, url, created }`. `created` is `false` when the event became a reply
in an existing conversation. A circle's streams take 60 events a minute from your app.

`sendEvent` makes one request and never retries. Pass an `idempotencyKey` if your own code retries.
Its errors:

| `_tag` | Status | Meaning |
| - | - | - |
| `ReintersectAppScopeMissingError` | 403 | The install lacks `replies:write`. |
| `ReintersectAppCircleNotFoundError` | 404 | The circle doesn't exist, or your app can't reach it or write to it. |
| `ReintersectAppStreamRevokedError` | 410 | Your app's stream in this circle was revoked. |
| `ReintersectAppStreamInvalidPayloadError` | 400 | The content is invalid, uses an unsupported node, or converts to nothing (`reason`). |
| `ReintersectApiRateLimitExceededError` | 429 | Over 60 events a minute. |

A circle admin can revoke your app's stream. A stream is also revoked when your app sends an event to
a circle it can no longer reach: that event answers `ReintersectAppCircleNotFoundError`. A revoked
stream stays revoked, and later events to it answer `ReintersectAppStreamRevokedError`; stop sending
them.

See the endpoint reference: [`POST /v0/streams/events`](/api-reference/streams/send-event).

### Streams without an install

A circle admin can also create a webhook URL for an outside tool, such as a deploy script, in the
circle's settings. That URL is its own credential: post to it with `createReintersectStream({ url })`
from `@reintersect/sdk`, without an app or an install. Use it for one circle and one tool; use
`streams.sendEvent` when your app serves many workspaces and circles.

## Replies and blocks

`replies.create` and `blocks.insert` take one target:

| Target | Where the message goes |
| - | - |
| `{ conversationId }` | Into that conversation. In a circle that uses chat, the message shows in the chat and is filed in the conversation. |
| `{ replyTo }` | A reply to that message. In chat it's an inline reply; on a board it's a reply under that reply, one level deep. |
| `{ circleId }` | Into the circle's chat, in circles that use chat. Reintersect may file the message into a conversation later, the same way it files members' messages. A circle that doesn't use chat answers `ReintersectAppCircleNotFoundError`. |

```tsx theme={null}
import { Paragraph } from "@reintersect/sdk/components";

const { data, error } = await reintersect.replies.create({
  circleId,
  content: <Paragraph>The nightly import finished with 3 warnings.</Paragraph>,
});
```

Replies answer with `circleId` and a `conversationId` that is `null` while the message isn't filed
in a conversation. Blocks carry `circleId` too. Events about a chat message, such as `reply.created`,
`app.mentioned` and `block.created`, carry `circleId` with a `null` conversation until it's filed;
subscribe to [`message.filed`](/events/catalog#message-filed) to hear when a message is filed or moved.

Replies need `replies:write` and blocks need `blocks:write`. See
[Create a reply](/api-reference/replies/create) and [Insert a block](/api-reference/blocks/insert).


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