Runtype
GuidesGuide

How to ship the same agent to your app, Slack and email without three codebases

How to run one AI agent in your app, Slack, email and an API without forking it per channel: the agent/surface boundary, per-channel differences, and testing.

Last updated 6 min read

Keep one agent and give each channel a thin surface. The agent owns behavior: model, system prompt, tools and policy. The surface owns transport, identity mapping and rendering. Threading, message length, attachments, latency and the question of who the user is stay per channel and get passed in as structured context, so the prompt never forks.

The situation

The agent works inside the product. Sales asks for it in Slack so account managers can query it without leaving a deal thread. Support wants it answering the shared inbox. Each request gets a quick service: a Slack bot with a copy of the prompt, an email worker with another copy, and the original in the app.

Within a month the three copies disagree. Someone tightened the refund policy wording in the app prompt after an incident and the Slack bot still quotes the old rule. The email worker got a new tool for order lookup that the app never received. A customer asks the same question in two places and gets two answers, and the ticket that follows is about the product, since the customer cannot see the seam.

Fixing one bug in three places is the visible cost. The invisible one is that the evals, if any exist, run against one copy, so two of the three deployed prompts have never been tested. This guide is part of a larger set on shipping an agent to production; the channel problem is the one that turns a single agent into a fleet.

How do I make my AI agent work in Slack and in my app

Separate the agent from its surfaces, and be strict about which side owns what. The test for any piece of logic is a question: would this change if the same message arrived over a different channel? If no, it belongs to the agent. If yes, it belongs to the surface.

ConcernOwnerWhy
Model, system prompt, temperatureAgentThe job is the same whoever asks and wherever they ask from
Tool set and tool credentialsAgentAn order lookup is an order lookup; the surface never sees the API key
Policy: approvals, redaction, spend limitAgentA gated tool is gated everywhere; the surface only changes how a decision is collected
Long-term memory and retrieval scopeAgentKeyed by tenant and end user, both of which the surface supplies as facts
Version (draft vs published)AgentOne promotion moves every channel at once
Webhook verification, event ack, retriesSurfaceSlack wants an acknowledgement within 3 seconds and redelivers otherwise; Twilio signs each request
Identity mappingSurfaceA Slack user id, a JWT subject, a phone number and an API key resolve to tenant and end user differently
Conversation keySurfaceA thread timestamp, a session id, a phone number pair, or a request-supplied id
Rendering and chunkingSurfaceMarkdown to mrkdwn, blocks, plain text with segment limits, or JSON
Attachments in and outSurfaceFile download from Slack, MMS media URLs, multipart on the API, none on MCP
Rate limits per channelSurfaceSlack and Twilio throttle differently; the agent's own spend cap is separate

The agent side reduces to a single definition that every surface references by name and version:

{
  "name": "support-assistant",
  "version": 12,
  "model": "claude-sonnet-5",
  "systemPrompt": "You answer questions about orders, refunds and shipping for {{tenant.name}}. ...",
  "tools": ["lookup_order", "issue_refund", "search_help_center"],
  "policy": {
    "approval": { "require": ["issue_refund"], "timeoutSeconds": 300 },
    "maxToolCallsPerTurn": 10,
    "redactPii": true
  },
  "memory": { "scope": "{{endUser.id}}" }
}

On the surface side, an adapter produces a channel-neutral envelope from whatever arrived, and consumes a channel-neutral reply. Everything the agent might need to know about the channel travels as data, never as prompt text the surface wrote.

Steps

1. Write the agent down once and reference it by version

Put the definition above in one place, whether that is a config file, a row in your database, or a runtime that stores it for you. Every surface points at support-assistant@12, and a promotion to version 13 is one change. If a channel genuinely needs different behavior (a shorter tone for SMS, say), express it as an instruction that reads channel context, such as "if channel.maxReplyChars is under 500, answer in two sentences", rather than as a second prompt.

2. Give each surface exactly three jobs

An adapter verifies the inbound request, maps it to the envelope, and renders the reply. Nothing else. The envelope carries the facts the agent cannot infer:

{
  "agent": "support-assistant@12",
  "conversationId": "slack:T024BE7LD:C0G9QF9GZ:1725370000.001200",
  "tenant": { "id": "acme", "assurance": "verified" },
  "endUser": { "id": "slack:U0G9QF9GZ", "assurance": "asserted" },
  "channel": {
    "kind": "slack",
    "maxReplyChars": 4000,
    "supportsButtons": true,
    "markup": "mrkdwn",
    "participants": 12
  },
  "message": { "text": "what happened to order 4471?", "attachments": [] }
}

The conversationId is the surface's choice of thread key, the assurance fields say how much the surface can vouch for each identity, and channel is the whole reason the prompt does not need to fork.

3. Map identity per channel, and say how sure you are

This is where multi-channel agents leak. In the app, the tenant comes from the session and the end user from a verified JWT. In Slack, the tenant is whichever customer installed the app into workspace T024BE7LD, looked up in your installation table, and the end user is the poster's Slack id, which you have not verified against anything in your own system. Over SMS, the only identity is a phone number, and a shared office line means the "user" may be several people.

Record the assurance level with the identity and let policy read it. A tool that reads a customer's own connected account should require a verified end user, so it works in the app and refuses in a Slack channel until that Slack user has linked their account. A tool that reads tenant-wide public data can accept an asserted identity. Without that distinction, the first Slack deployment quietly grants every workspace member whatever the app granted to logged-in users.

4. Handle the differences that do not abstract away

Some channel behavior cannot be hidden behind the envelope, and pretending otherwise produces the classic multi-channel bugs: a 3,000-character answer arriving as nineteen SMS segments, or an agent replying at the top level of a busy Slack channel instead of in the thread it was asked in.

DifferenceWeb chatSlackREST APISMS and iMessageMCP and A2A
Who the user isOne authenticated personPoster of each message; the thread may hold twelve peopleWhatever the caller assertsA phone number, possibly sharedAnother program, on behalf of someone
Conversation keySession idChannel id plus thread timestampCaller-supplied id, or statelessThe number pairPer call, usually stateless
Reply lengthEffectively unboundedAbout 4,000 characters per messageUnbounded160 GSM-7 or 70 UCS-2 characters per segmentStructured result, size-limited by host
AttachmentsUploadsFiles via a download URLMultipart or URLsMMS media URLs, iMessage attachmentsUsually none
Latency expectationStreaming, secondsAck in 3 s, answer within a minute is fineSynchronous, or a job id and pollingMinutes are acceptableTool timeout of the calling client
Approval collectionOften noneNative approve and deny buttonsNone for an unattended callerRead from the reply textNone
RenderingMarkdownmrkdwn, or blocks at 3,000 characters per sectionJSONPlain text, no links you would trust to unfurlSchema-typed output

Two of these deserve a rule. On length, the agent should never learn what a segment is; the surface passes maxReplyChars and, when the reply still exceeds it, chunks on paragraph boundaries rather than mid-sentence. On approvals, the policy lives in the agent, but the surface has to declare whether it can collect a decision, and a gated tool on a surface that cannot should refuse the run before the agent starts, with an error naming the policy, rather than leaving a pending approval that nobody can answer. An SMS surface also has to avoid treating STOP as a denial, since the carrier treats it as an opt-out keyword.

5. Test one agent against several surfaces

The eval set belongs to the agent, so run it once per surface with the envelope changed and the prompt untouched. A useful minimum is three sets:

  • Behavior: the same twenty cases run through each adapter, asserting on the agent's reply text before rendering. This catches a surface that altered meaning while formatting.
  • Rendering: fixed replies pushed through each renderer, checking chunk boundaries, that a Slack reply lands in the originating thread, and that a JSON reply validates against the API schema.
  • Identity: one case per surface where the assurance level should block a tool, asserting the refusal.

Record the surface on every execution trace so a regression can be filtered by channel. When the Slack bot is "worse than the app", the trace either shows a different agent version (a deployment problem) or the same version with a different envelope (a surface problem), and the two have different owners.

The channel-specific setup is covered in putting an agent in Slack and exposing an agent as an API. If the app itself does not have the assistant yet, start with adding an AI assistant to an existing product and add channels once the agent definition is stable.

Where this gets easier

This is the shape Runtype is built around: one agent definition, many surfaces (web chat, Slack, REST API, SMS, iMessage, MCP, A2A and email) with model, prompt, tools, approval policy, redaction and versioning defined once and each surface owning transport, identity and rendering. Slack surfaces render native approve and deny buttons, SMS and iMessage read the decision from the reply, and a surface with no way to collect one refuses an approval-gated run before it starts, as described in approvals on messaging surfaces. Tenancy strategy per resource (internal, tenant-isolated, end-user-isolated) with an asserted or verified assurance floor is the identity contract from step 3, enforced before execution, and every trace records which surface the request came through.

Frequently asked questions

Should each channel get its own system prompt?
No. Keep one system prompt that describes the job, and pass channel facts (reply length limit, whether buttons exist, who is in the conversation) as structured context on each request. A per-channel prompt is the first thing that drifts, because the person fixing a Slack complaint edits the Slack copy and nobody edits the others.
How do I handle a Slack channel where twelve people can talk to the agent?
Treat the thread as the conversation and the poster of each message as the end user. The tenant is the workspace, resolved from the Slack team id through your installation records. Anything the agent remembers or retrieves must be scoped to that tenant, and anything personal (a connected account, a private record) must be scoped to the individual poster, never to the channel.
Can the same agent ask for human approval on every channel?
The policy can be the same, but the collection mechanism cannot. Slack can render approve and deny buttons, SMS and iMessage have to read the decision from the reply text, and a web chat widget or an unattended API call may have no way to collect one at all. Decide per surface whether a gated tool is approvable there, and refuse the run up front where it is not, rather than leaving it pending.