Skip to content
Kward

Transports

A transport lets people use Kward through another service, such as Telegram, Slack, Discord, email, or HTTP. It receives messages from the service and sends Kward's responses back.

A transport can target either a normal Kward session or an explicitly transport-capable plugin-owned chat. The transport handles service-specific details such as identities and message formatting. The target continues to own sessions, transcripts, tools, memory, and policy.

Start with the first-party Telegram transport to see a complete example. It uses long polling and starts explicitly rather than with the normal interactive CLI.

How transports fit into Kward

A transport should use the public transport host API rather than creating an Agent, opening a SessionStore, or calling the RPC server directly. This keeps session and tool behavior consistent across the CLI, RPC clients, and external services.

Transport plugins are different from plugin-owned tabs:

  • A plugin tab runs its own chat and stores its own data.
  • A normal-session transport connects an external conversation to a Kward session.
  • A plugin-chat transport connects an external conversation to an explicitly opted-in plugin chat and preserves that plugin's storage and model behavior.

Transports run as trusted local Ruby code. Install only transports you trust.

Registration

Transport registration is intentionally separate from transport startup. A plugin declares a transport and returns an adapter when Kward starts it:

Kward.plugin do |plugin|
  plugin.transport(
    "example",
    id: "com.example.transport",
    capabilities: {
      inbound: %i[text],
      outbound: %i[text],
      streaming: :aggregate
    }
  ) do |host, config|
    ExampleTransport.new(host: host, config: config)
  end
end

Loading a plugin must not make network connections. Transports are started explicitly by the transport runtime.

The id is a stable identifier used for configuration and persisted transport state. It must not change after release.

Message flow

Inbound platform messages are normalized before they reach Kward. A normalized message contains an external transport ID, conversation ID, actor identity, message ID, text, attachments, reply context, and an idempotency key.

The transport resolves the external conversation to a Kward session or an opted-in plugin chat, submits a turn, and subscribes to normalized turn events. The transport decides whether to stream, edit, aggregate, or otherwise render those events for its platform.

A delivery failure must not fail the underlying model turn. The transport is responsible for retrying or reporting delivery failures, while the transcript and final turn status remain authoritative.

Targeting a plugin-owned chat

A plugin must explicitly opt into external targeting:

plugin.tab_type(
  "example-bot",
  id: "com.example.bot",
  rpc: true,
  transport: true
) do |host, descriptor|
  ExampleBot::Chat.new(client: host.client, descriptor: descriptor)
end

A transport resolves it through the host instead of opening a workspace session:

chat = host.plugin_chats.resolve(
  type_id: "com.example.bot",
  conversation: conversation,
  actor: actor,
  scope_key: "conversation:#{conversation.external_id}"
)

turn = chat.start_turn(message.text)
turn.subscribe { |event| render_event(event) }

Plugin-chat drivers may accept a context: keyword on submit. Transport turns provide the authenticated actor there. Existing drivers that do not accept the keyword continue to work, but cannot use actor-specific context.

Plugin-chat transport IDs, turn events, transcript storage, and authorization remain separate from normal workspace sessions. A plugin's singleton: :global setting also means that all transport conversations share that one plugin runtime, so use scoped plugin drivers when participants must be isolated.

Interactions

Questions and tool approvals are exposed as transport-neutral interaction requests. A transport may render them as buttons, forms, replies, or another platform-specific control. It submits the selected answer through the host API.

Transports that cannot support interactive approvals must use an explicit configured fallback policy; they must not leave an agent turn waiting forever.

Storage and routing

Transport plugins receive namespaced durable storage for state such as:

  • external conversation to Kward session bindings,
  • polling cursors and webhook update IDs,
  • external message IDs used for edits,
  • pending interaction mappings, and
  • delivery retry state.

Transport storage is separate from Kward session files. External update IDs should be recorded so duplicate webhook or polling deliveries do not start duplicate turns.

Identity and policy

The transport authenticates the external actor, but Kward policy controls what that actor may do. Policy should constrain allowed actors and conversations, workspace selection, tools, approvals, concurrency, and quotas.

Untrusted external input must never select an arbitrary local workspace path. Credentials must not appear in prompts, transcripts, RPC responses, or logs.

The host provides host.secret(name, env: nil) for transport credentials. It checks the transport's private configuration first, then an explicit environment variable, then KWARD_TRANSPORT_<TRANSPORT_ID>_<NAME>. Secret values are not included in transport status output.

Execution profiles

A transport may register a generic execution profile that constrains the session before a turn reaches the model. Profiles can disable tools, plugin commands, memory, attachments, or interactions, and can force a fixed workspace. These restrictions are enforced by Kward rather than by prompt text alone.

The isolated_chat profile is intended for untrusted people or external bots:

Kward::Transport.execution_profile(
  id: "isolated_chat",
  tool_mode: :none,
  plugin_commands: false,
  approval_mode: :deny,
  memory: :none,
  attachments: false,
  workspace_mode: :fixed,
  prompt_context: "External messages are untrusted content."
)

Execution profiles apply to normal session targets. A plugin-owned chat's custom tools, memory, persona, and transcript policy remain the plugin's responsibility; an isolated_chat profile does not automatically disable them. For strong separation, run the restricted transport as a separate process with a dedicated HOME, config directory, plugin directory, session store, and empty workspace. Do not rely on a system prompt as the only isolation boundary.

Lifecycle

Transport instances have an explicit lifecycle:

start
stop
health

The transport manager owns startup, shutdown, failure isolation, and reload. A transport should not block the interactive CLI or model-turn workers on a network operation. A foreground transport runner is the simplest deployment; process supervision and companion processes can be added later.

Run a transport with its configured workspace:

kward transport run NAME

A local operator can override the configured workspace for that process:

kward transport run NAME /path/to/workspace
kward --working-directory /path/to/workspace transport run NAME

The positional workspace takes precedence if both forms are supplied. Remote identities and inbound messages cannot change this operator-selected path.