The five-minute path to a working OpenTag is in the README quick start. This file is the reference behind it: components, the full environment contract, Channel commands, optional sources, Railway, and tests.
The canonical deployment is one Python agent service and one Node CopilotRuntime service with Channels embedded. Slack and Microsoft Teams are supported; Discord, Telegram, and WhatsApp are coming soon.
| Component | Location | Responsibility |
|---|---|---|
| Runtime entrypoint | server.ts |
Environment, Channels readiness, HTTP lifecycle, and shutdown |
| Application composition | app/index.ts |
SDK agent factory, managed Channel, and runtime |
| Channel definition | app/channel.tsx |
Mentions, commands, components, modals, and interrupts |
| Intelligence runtime | app/runtime-host.ts |
One CopilotKitIntelligence and one CopilotRuntime |
| Environment contract | app/env.ts |
Required variables and in-code defaults |
| Python agent | agent/ |
LangGraph deep agent served over AG-UI |
| Railway topology | .railway/railway.ts |
Two services sourced from OpenTag main |
| AWS topology | deployment/aws/ |
One private Fargate task, images, secrets, and Datadog log forwarding |
The host always uses the Intelligence-owned runtime. It declares one adapter-free Channel using the configured name, with no exceptions: the Slack and Microsoft Teams adapters, their credentials, and attachments are configured only in Intelligence, and no platform token is read here.
Prerequisites:
- Node.js 22+
- pnpm
- Python 3.12
uv- A CopilotKit Intelligence project, Channel, and runtime API key (free plan available) — or an alternative Channels SDK channel runner
- An OpenAI API key for the Python agent
pnpm install --frozen-lockfile
cd agent
uv sync
cd ..@copilotkit/channels and @copilotkit/runtime are intentionally pinned.
package.json is the single source of truth for both
versions; this file does not restate them, because a hand-copied pin drifts on
the next bump.
cp .env.example .envOne root .env configures both services. The Python agent loads it explicitly
for local development; Railway supplies the same values as service variables
without a checked-in file.
| Variable | Required | Purpose |
|---|---|---|
AGENT_DISPLAY_NAME |
No | User-facing identity used by the agent persona and capability UI; defaults to OpenTag |
Set the same value on both services when they do not share an environment. For
example, AGENT_DISPLAY_NAME=Kite makes the agent introduce itself and render
its capability showcase as Kite without renaming the OpenTag project, services,
or Channel slug.
| Variable | Required | Purpose |
|---|---|---|
OPENAI_API_KEY |
Yes | Model access |
OPENAI_MODEL |
No | Defaults to gpt-5.5 |
OPENAI_REASONING_EFFORT |
No | Defaults to low |
OPENAI_VERBOSITY |
No | Defaults to low |
TAVILY_API_KEY |
No | Enables live web research |
COMPOSIO_API_KEY |
No | Master switch for Composio toolkits. Absent means the feature is never constructed |
COMPOSIO_TOOLKITS |
No | Toolkit slugs everyone shares one connection for |
COMPOSIO_USER_TOOLKITS |
No | Toolkit slugs scoped to whoever sent the message. Each person connects their own account from a Slack thread; a non-empty AGENT_AUTH_HEADER is required before a link is minted |
COMPOSIO_APPROVALS |
No | on (default) or off. destructive and writes are the old spellings and still parse as on. An unrecognized value fails startup, but only once Composio is configured — with no API key or no toolkit list the variable is never read |
COMPOSIO_WORKSPACE_USER_ID |
No | Composio user_id the shared toolkits run as. Defaults to this service's INTELLIGENCE_CHANNEL_NAME, and to open-tag when that variable is not set on the agent |
INTELLIGENCE_CHANNEL_NAME |
No | Also read here, not only by the runtime: it is the default shared-toolkit user_id above. The agent's own fallback is open-tag, so an overridden Channel name has to be set on both services or the shared identity differs between them |
COMPOSIO_AUTH_CONFIGS |
No | toolkit:auth_config_id pairs, ids case-sensitive. Pins which auth config a toolkit connects against when it has several. Unset, Composio picks one from the project |
AGENT_AUTH_HEADER |
No | The runtime's shared secret. Checked when set to a non-empty value, and required — non-empty — before a Composio connect link is minted; "" reads as unconfigured |
GITHUB_PERSONAL_ACCESS_TOKEN |
No | Enables read-only GitHub repository, code, PR, Actions-run, and job-log search. It remains the legacy coding fallback |
GITHUB_MCP_URL |
No | Overrides the hosted GitHub MCP URL; OpenTag still sends read-only headers |
DAYTONA_API_KEY |
No | Enables the coding subagent (Daytona sandbox) |
DAYTONA_SNAPSHOT |
No | Optional Daytona snapshot id. If unset, the first command probes the box. git and pnpm install only when needed. The default snapshot already has Node. pnpm is enabled with Corepack in $HOME/.local/bin |
DAYTONA_TTL_MINUTES |
No | Daytona box TTL in minutes. Defaults to 60 |
GITHUB_CODER_TOKEN |
No | Preferred PAT coding credential. Mutually exclusive with complete GitHub App credentials |
GITHUB_APP_ID |
No | GitHub App ID; all three App variables are required together |
GITHUB_APP_INSTALLATION_ID |
No | Single supported GitHub App installation ID |
GITHUB_APP_PRIVATE_KEY_BASE64 |
No | Base64-encoded GitHub App private-key PEM |
POSTHOG_PERSONAL_API_KEY |
No | Enables the hosted PostHog MCP in read-only CLI mode |
POSTHOG_MCP_URL |
No | Overrides the hosted PostHog MCP URL |
LINEAR_API_KEY |
No | Enables the hosted Linear MCP |
LINEAR_MCP_URL |
No | Overrides the hosted Linear MCP URL |
NOTION_MCP_AUTH_TOKEN |
No | Bearer token for a remote Notion MCP; requires NOTION_MCP_URL |
NOTION_MCP_URL |
No | Remote Notion MCP endpoint; requires NOTION_MCP_AUTH_TOKEN |
CORS_ALLOW_ORIGINS |
No | Comma-separated allowed origins; defaults to * |
SERVER_HOST |
No | Local bind host; defaults to 0.0.0.0 |
SERVER_PORT |
No | Local/container port; defaults to 8123 |
AGENT_RELOAD |
No | Local development reload; disabled by default |
To check a live Daytona box (create, echo, git, then delete):
uv run --directory agent python scripts/probe_daytona.pyOnly OPENAI_API_KEY is required. Coding stays off until DAYTONA_API_KEY and
a PAT or complete GitHub App configuration are set. If both explicit methods are
configured, or the App configuration is incomplete, coding stays off and startup
logs the configuration problem. GITHUB_ALLOWED_REPOS is no longer enforced;
if it remains configured, startup warns that GitHub permissions define access.
GitHub MCP stays read-only even when coding is on.
Implementation jobs require a scoped brief with files, the exact change, and a
test command; repair and merge jobs may inspect the checkout and CI logs to
identify those details. Slack does not say "open the PR" unless the user named
a PR. If Slack cuts the live update, the job may still be running. Without
Tavily or internal-source
credentials the agent still chats, triages, and renders supported UI
components; planning and virtual files remain available for explicitly
substantial work.
Run it alone:
pnpm agentThe AG-UI endpoint is http://localhost:8123/; /health reports the
opentag-agent service.
| Variable | Required | Purpose |
|---|---|---|
AGENT_URL |
Yes | Python AG-UI endpoint, locally http://localhost:8123/ |
INTELLIGENCE_API_KEY |
Yes | Runtime authentication; also selects the project |
INTELLIGENCE_CHANNEL_NAME |
No | Defaults to open-tag; must match the Channel name exactly |
INTELLIGENCE_LEARNING_CONTAINER_ID |
No | Assigns OpenTag Threads to this existing Learning Container |
INTELLIGENCE_API_URL |
No | Defaults to https://api.intelligence.copilotkit.ai |
INTELLIGENCE_GATEWAY_WS_URL |
No | Defaults to wss://realtime.intelligence.copilotkit.ai |
AGENT_AUTH_HEADER |
No | Shared secret between runtime and agent. Sent as Authorization; the agent checks it when set to a non-empty value, and requires a non-empty one before minting a Composio connect link |
PORT |
No | Channel HTTP port; defaults to 3000 |
LOG_LEVEL |
No | Defaults to error. Channel lifecycle breadcrumbs are emitted at warn, so set warn or lower to see them |
MERMAID_URL |
No | Overrides the Mermaid browser bundle URL used by diagram rendering |
The API key selects a project; the Channel name selects a Channel inside it.
When INTELLIGENCE_LEARNING_CONTAINER_ID is set, it must name an existing
Learning Container in that same project. Omitting it preserves the default
behavior and leaves OpenTag Threads unassigned to Learning.
Legacy organization, project, Channel ID, and runtime-instance ID variables are
not used. Slack and Teams credentials do not belong here — Intelligence owns
them.
Both Intelligence URLs are defaulted in app/env.ts rather than
in .env. That is deliberate, and it is why copilotkit channels status
reports them as unset. A genuinely missing INTELLIGENCE_GATEWAY_WS_URL does
not error: the realtime plane is a different host from the API plane and is not
derived from it, so channels.ready() simply hangs until it times out.
Start the runtime:
pnpm runtimepnpm start and pnpm runtime run the same canonical entrypoint; pnpm dev
adds watch mode for both services. Startup waits for
listener.channels.ready() before opening HTTP. SIGINT and SIGTERM stop
Channels, HTTP, and the rendering browser exactly once, even if shutdown is
requested more than once.
Note that ready() resolving is not proof of health. It also resolves on
setup_required, which is a valid degraded state rather than a failure. Only
controls.status() → { overall, channels } distinguishes them, and
/api/copilotkit/info returning 200 reports license and runtime state while
saying nothing at all about Slack.
When an agent run fails, Slack gets a short reason (live update cut after about a minute, dropped connection, coder recursion, or the error text). If the user named a GitHub PR, that URL is in the message. Slack does not get a stack trace.
The Channel is created and reconciled with the public CopilotKit CLI. These
commands configure managed Intelligence Channels; they do not configure the
open-source @copilotkit/channels adapter packages, which are a separate
product sharing the words "channels" and "Slack".
| Command | Purpose |
|---|---|
copilotkit project select |
Select or create the hosted Intelligence project |
copilotkit channels add [name] |
Declare a Channel, reconcile it, and report the next step |
copilotkit channels status |
Compare your configuration, your code, and the server |
copilotkit channels list |
List Channels and their attachment state |
copilotkit channels rotate <name> |
Replace stored provider credentials |
copilotkit channels providers |
List providers and the credentials each asks for |
copilotkit channels setup |
Install the channels-setup skill and hand the flow to your coding agent |
copilotkit skills onboard --channels |
The same prompt, but --agent narrows which agents it installs to |
No flag accepts a credential value. Credentials are read from .env, from a
named variable via --credential-env <field>=<VAR>, or from a JSON document on
stdin via --credentials-stdin for CI and secret managers. --json implies
non-interactive: it never prompts and never opens a browser.
channels add writes .copilotkit/channels.json. Keep that file tracked; keep
.env and .copilotkit/artifacts/ ignored.
The channels-setup skill installed by channels setup is a pointer, not a copy
of the steps: it fetches its workflow from
https://copilotkit.ai/channels-guide.md at run time so it cannot go stale
against the CLI. That workflow assumes a project starting from nothing, so it
includes phases for building the agent and writing the Channel runtime — OpenTag
has both already. Its Slack handoff never asks anyone to paste a secret into chat.
| Provider | Fields |
|---|---|
slack |
channelToken — Bot User OAuth Token (xoxb-), from OAuth & Permissions; signingSecret, from Basic Information → App Credentials |
teams |
clientId and tenantId, from the Entra app registration Overview; clientSecret — the secret Value, not the Secret ID |
There is no app-level xapp- token on the managed path, and nothing in OpenTag
reads one. Slack reaches Intelligence over HTTPS at an Intelligence-hosted
Request URL, authenticated by the signing secret Intelligence holds, and
Intelligence reaches your runtime over a websocket your process opens outbound.
copilotkit channels add --adapter teams --provision can create the
provider-side Teams app for you. Two Teams gates stay user-owned regardless:
granting tenant admin consent, and uploading the app package through Apps →
Manage your apps → Upload an app.
Socket Mode stays off on the Slack app, permanently. Managed delivery never uses it, and a Slack app with Socket Mode enabled installs green and delivers nothing to the Request URL — which is to say, nothing to Intelligence and nothing to your runtime. It fails silently and it looks like success.
This is not hypothetical. OpenTag once attached its own Socket Mode adapter so a
Composio connect link could reach one person privately; from 24 August until
that adapter was turned off, Slack delivered events over the socket and stopped
posting them to Intelligence, so the managed path was dead. That adapter and its
two tokens have since been removed from this repository outright — private
delivery is the managed adapter's job with the SDK pair pinned in
package.json, and nothing here reads a Slack token any more.
Managed delivery is claim-based. Two runtimes declaring the same Channel name in
the same project race per delivery, and the loser silently receives nothing —
the tell is a Slack reply your terminal knows nothing about. Give a local or
forked runtime its own project, key, and Channel name rather than reusing
open-tag.
The name is a slug: lowercase, digits, single hyphens. It must match
INTELLIGENCE_CHANNEL_NAME character for character.
OpenTag registers:
/agent <text>to run a mention-free prompt./triage [note]to summarize and propose Linear issues./preview <title>to preview an issue privately where supported./file-issueto open a form where supported, with a conversational fallback.
The Channel also forwards sender context, Slack-specific tools on Slack turns, file content, and rich issue/page/table/native-Slack-chart/diagram/status/ incident/link components.
Trigger routing is not symmetric. A mentioned turn goes to onMention if
registered and falls back to onMessage otherwise; an unmentioned turn reaches
onMessage only. onMention subscribes the thread, which is what lets
unmentioned follow-ups in that thread run the agent. Always verify with a
channel mention first.
Mentions, messages, and button and select clicks are the proven managed-path triggers — interactivity is enabled deliberately, which is what makes human-in-the-loop fire. Slash commands and modals are registered in code but their managed-path delivery depends on the Channel's generated Slack manifest declaring them, which is decided server-side by Intelligence rather than by anything in this repository. A handler that is never delivered still compiles, starts, and reports online, so send a real command and submit a real modal against your own Channel before relying on either.
Before a Linear or Notion mutation reaches MCP, a Python interceptor emits
confirm_write. The Channel posts an approval card, and the button resumes the
graph with the user's decision. The MCP handler runs only after approval. Reads
and UI rendering are never gated.
Set TAVILY_API_KEY to enable live web research. The web_search tool is not
registered when the key is absent.
Set GITHUB_PERSONAL_ACCESS_TOKEN to enable GitHub search. Use a fine-grained
personal access token limited to the repositories and read permissions the agent
needs. OpenTag connects to GitHub's hosted MCP with an explicit allowlist of
read-only repository, pull-request, Actions-run, and job-log tools. Every loaded
tool must advertise readOnlyHint; triggers, reruns, cancels, deletes, and other
writes are excluded. Set GITHUB_MCP_URL only
to override the hosted endpoint, then restart pnpm agent so it rediscovers the
tools.
For coding, prefer a fine-grained GITHUB_CODER_TOKEN; classic PATs continue to
work. Alternatively, set all three GitHub App variables. A search PAT may coexist
with App coding. The required repository permissions are Contents: read/write,
Pull requests: read/write, and Metadata: read-only. Add Actions: read
for CI inspection and Workflows: write only when the agent must modify workflow
files. Installation-selected repositories are the App authorization boundary.
OpenTag does not request or configure branch-protection bypass.
Credentials stay on the OpenTag host. Daytona receives the current token only on
clone, pull, and push API calls; the sandbox receives no GitHub environment
variable, credential helper, authenticated remote, App JWT, or private key. The
coder commits locally, then one confirm_write covers its push and draft-PR
create/update. If the push succeeds and the PR write fails, retrying performs only
the PR write.
Create a PostHog personal API key using the MCP Server preset, then set
POSTHOG_PERSONAL_API_KEY. OpenTag connects to https://mcp.posthog.com/mcp in
token-efficient CLI mode with server-enforced read-only access. Set
POSTHOG_MCP_URL only to override the complete endpoint, including its
mode=cli&readonly=true safety parameters. Restart pnpm agent after changing
either variable.
Set LINEAR_API_KEY. OpenTag connects to the hosted Linear MCP by default.
Railway preserves this optional secret on the agent service.
Notion is optional and remote-only, not a separate Railway service. Set both
NOTION_MCP_URL and NOTION_MCP_AUTH_TOKEN, then restart pnpm agent so it
discovers the tools. If either value is absent OpenTag skips Notion without
blocking startup.
Composio adds a toolkit — Gmail, Linear, Jira, Google Calendar, Salesforce —
without a new MCP block, a preserve() line, or a matching test assertion. It
lives in the Python agent, alongside every other capability, and is gated by the
same confirm_write card that already guards a Linear or Notion write. There is
one approval mechanism in this product, not two.
Setup is three steps per app, not one:
-
Add the toolkit at https://app.composio.dev. That creates its auth config.
-
Add its slug to
COMPOSIO_TOOLKITSorCOMPOSIO_USER_TOOLKITS. A shared toolkit also needs connecting once:cd agent && uv run python -m composio_tools.connect_cli <toolkit>
Open the link it prints, signed in as the account the team should share. That needs no running agent, so do it before you restart. Personal toolkits skip this — each person connects their own from a thread.
-
Restart the agent, once.
The slug is the tricky part. It is Composio's own, lowercase and unspaced:
Google Calendar is googlecalendar, not google-calendar or gcal. Take it
from the toolkit's page URL at https://app.composio.dev (/toolkit/gmail), or
from the Toolkits list in their docs. A typo is silent — OpenTag does not
validate slugs against Composio at startup, so a misspelled toolkit is simply one
that never appears: the agent has no tools for it and search_my_tools never
mentions it. If an app you configured seems absent, check the spelling first.
COMPOSIO_API_KEY is the master switch. Without it nothing is constructed — no
client, no session, no tool the model can see but must not call. A key with both
toolkit lists empty is equally inert.
COMPOSIO_TOOLKITS runs every Slack user through one connection, under the
Composio user_id in COMPOSIO_WORKSPACE_USER_ID — defaulting to the agent's
own INTELLIGENCE_CHANNEL_NAME, and to open-tag when that is unset there.
That is right for the team's Linear or Jira. The connect script below reads the
same two variables from wherever you run it, so an id that differs between your
shell and the deployment connects an account no turn will look up.
COMPOSIO_USER_TOOLKITS scopes to whoever spoke, keyed by their verified
platform actor and the platform it came from — a provider id is unique only
within its provider, so U1 on Slack and U1 on Teams are different people. You
ask about "my calendar" and get yours; your colleague gets theirs. A turn with no
resolvable actor gets no personal tools at all and never falls back to the shared
identity.
Both lists may be set at once, and one turn can use both. A toolkit named in both resolves to the personal scope only.
How an account gets connected differs by list, and this is where the surprises are:
- Personal. The agent calls
connect_app, which posts a public Connect card carrying no link. On Slack, whoever clicks receives a one-time link privately, minted for them; somebody else clicking the same card connects their own account. A pre-minted link posted in a channel would be an account-takeover hazard, because whoever completes the flow binds their account to the id the link was minted for. - Shared. Nobody in Slack can connect it, and neither can the dashboard — a connection made there binds to the dashboard's own user id, which this deployment never passes. It is a test button. The connect script above is the only correct path.
Personal toolkits work, and Slack is the surface they work on. The agent
learns who spoke from forwardedProps.channelActor, which the pinned
@copilotkit/channels forwards on every run of a turn. A clean install of this
repository resolves the speaker with nothing configured for it.
Delivery is the other half, and it is where the surfaces differ. A connect link has to reach one person alone. On Slack it does: the Connect card is public in the thread, the link is minted when somebody clicks and goes to that person privately, and the whole flow is proven end to end against a live workspace. Teams has no ephemeral message, so a Teams-backed Channel posts the card and then tells the clicker the link could not be sent privately — it is discarded rather than posted where the thread can read it.
The pin in package.json says what should be installed. This
says what is:
grep -rl channelActor node_modules/.pnpmOutput names the packages that carry the field. Silence means the installed Channels predates it and every turn will read as anonymous — reinstall, and if it is still silent, check the pin.
Upgrading an older installation. Older SDKs did not forward the speaker,
so COMPOSIO_USER_TOOLKITS could be configured and silent — search_my_tools
listed no personal tool and no Connect card was ever posted. Install the SDK
pair pinned in package.json to enable forwarding; no
environment variable enables it. Two things then have to be true before a link
is minted: the Slack-backed Channel above, and a non-empty
AGENT_AUTH_HEADER on both services, described below.
That forwarded value is the only thing the agent will treat as an identity, and four rules follow from it. They fail closed — each one costs access to a personal toolkit and none of them grants it:
- A
channelActorin a request's ownstateis discarded. The AG-UI adapter merges caller state over forwarded properties, so without this the body would decide whose account a turn runs in. - A turn that forwards nobody is anonymous, and clears whoever spoke last. The graph is checkpointed per thread, so an inherited actor would let a second person in a Slack thread act as the first.
- Only
slackandteamsare recognised surfaces. Adding one means adding it toKNOWN_PLATFORMSinagent/composio_tools/state.py; until then its turns read as anonymous rather than sharing a namespace with everybody else's. - Only
kind: "human"gets a personal identity. Abot,apporsystemactor — a workflow posting on somebody's behalf — reaches the shared toolkits and no personal one, and cannot be minted a connect link. The Channels SDK documentskindas the provider's own metadata rather than an authorization claim, which is exactly why it is read as a filter and never as a grant: it can only take a personal toolkit away, never hand one over.
Beyond a Slack-backed Channel, personal toolkits need one thing configured:
AGENT_AUTH_HEADER, non-empty, on both services. The runtime asks the agent to mint each link, and the agent refuses to mint one without this secret. A link is a bearer capability; there is no configuration in which handing one to an unauthenticated caller is right. Empty is not configured:configured_secretinagent/agent_auth.pystrips the value and treats""as absent, so a deployment that ships the variable set to an empty string refuses every mint. Ordinary agent traffic is checked only when the variable holds a value, so an existing deployment is unaffected until it opts in.
Delivery itself needs no configuration here, and no platform token — this app
holds no Slack credential to deliver with. The managed adapter puts the link in
front of the clicker alone. Where a surface offers no private message the Connect
button discards the minted link rather than posting it in the thread, says so to
the person who clicked, and logs what an operator should check. See
app/tools/connect-click.tsx.
COMPOSIO_APPROVALS is on (the default) or off. A gated call posts the same
card as a Linear or Notion write and pauses the graph, so the answer can arrive
twenty minutes later and the model still sees the result.
A tool's effect comes from Composio's own behaviour tags, looked up per slug.
readOnlyHint is the only thing that takes a call out of the gate. Everything
else is gated, including a slug that cannot be classified — which covers both a
lookup that failed and a tool the lookup found carrying no behaviour tag. Only
the lookup's own answer is remembered, never the fail-safe one, so a tool is not
permanently mislabelled by one bad lookup.
destructive and writes were separate modes and are now one. They gated an
identical set and always would have. The tags can say exactly two things —
readOnlyHint and destructiveHint — so there is no way to express "a write
that is definitely not destructive", and idempotentHint cannot stand in for
one, because DELETE is idempotent. Since an unclassified tool is gated as
destructive rather than guessed at, every call is either a read or destructive,
and choosing between the two modes was choosing between two spellings of one
behaviour. Both still parse as on, so an existing deployment does not fail at
boot on upgrade; there is nothing to change unless you want the new name.
A call that runs in one person's own account names that person as its approver, and only they can answer the card — approving it spends their access and nobody else's. Somebody else pressing it is told so, privately where the surface allows one and in the thread where it does not, and the card stays up for its owner. Both buttons share one durable decision, so only one click can resume the agent, including after a runtime restart. Each answer also names its original graph interrupt; a delayed click cannot approve a newer pending action.
Upgrade the runtime and agent together for this approval protocol. Cards created before the upgrade cannot be resumed safely and require a fresh request. If the agent cannot provide an interrupt ID, the runtime refuses the approval and asks the operator to update the agent.
Sessions are created with connection management off. The connect flow above is the only way an account is linked, because it is the only one that binds the connection to an actor the platform verified.
The IaC file declares exactly:
agent:CopilotKit/OpenTag, branchmain, rootagent, Railpack,/health, port8123.runtime:CopilotKit/OpenTag, branchmain, repository root,pnpm runtime,/api/copilotkit/info, port3000.
runtime.AGENT_URL references the agent's Railway private domain and port.
Production Intelligence URLs are literal configuration, the API key is
preserved, and the Channel name is the literal open-tag on both services —
the agent's copy is what shared Composio toolkits default their user_id to.
AGENT_DISPLAY_NAME is preserved independently on both services and must match
when overridden. OPENAI_API_KEY is required on agent; Tavily, Daytona/coder,
GitHub, PostHog, Linear, and the paired remote Notion variables are optional
preserved settings.
The variables this change added are preserved too, and which service carries them is the whole design:
- On
agent:COMPOSIO_API_KEY,COMPOSIO_TOOLKITS,COMPOSIO_USER_TOOLKITS,COMPOSIO_APPROVALS,COMPOSIO_WORKSPACE_USER_ID,COMPOSIO_AUTH_CONFIGS. The toolkits live in the agent, so the Composio credential never reaches the runtime. - On
runtime: nothing. Delivery is the managed adapter's job, so the runtime carries no platform credential. - On both:
AGENT_AUTH_HEADER. It is a shared secret, so the two values have to match; they are preserved independently and Railway will not reconcile them for you.
Evaluate the configuration locally without applying it:
node node_modules/railway/dist/iac/bin.jspnpm install --frozen-lockfile
pnpm check-types
pnpm test
cd agent && uv run pytestThe Slack API live harness is separate from unit tests:
pnpm e2eSee e2e/README.md for its required workspace credentials.
There is no launch-blocking Teams E2E harness.
Discord, Telegram, and WhatsApp are intentionally not configured. Their adapters and setup instructions will be added once launch support is ready.