Channels

A channel puts your agent somewhere people already are — Slack, email, WhatsApp, Discord, SMS, your own app’s webhook — without changing the agent itself. The same tools, the same approvals, the same ledger. Only the surface changes.

bin/rails generate silas:channel whatsapp

That scaffolds both halves and the route that joins them. The rest of this page is the contract they implement, so you can fill in the three TODOs correctly and know what you’re getting for free.


The two halves

A channel is inbound and outbound, and they are deliberately separate: inbound is a web request, outbound is a background job. They meet at one name.

app/agent/channels/whatsapp.rb                        outbound  Agent::Channels::Whatsapp
app/controllers/agent/channels/whatsapp_controller.rb inbound   Agent::Channels::WhatsappController
config/routes.rb                                      POST /agent/channels/whatsapp

Identity is the filename. whatsapp.rb defines Agent::Channels::Whatsapp, sessions started through it carry channel: "whatsapp", and Silas uses that string to find the class again at delivery time. Rename the file and the channel is renamed; delete it and the channel is gone.

The webhook controller lives in your app, not the engine, because only your app knows the vendor’s signature scheme and payload shape. Slack is the one exception — Silas ships its routes and verification because it ships Slack’s whole dialect.


Inbound: verify, key, dispatch

class Agent::Channels::WhatsappController < ActionController::Base
  skip_forgery_protection          # no browser session -> no CSRF token can exist
  before_action :verify_webhook!   # ...so the signature IS the authentication

  def create
    Agent::Channels::Whatsapp.dispatch(
      thread_key: params[:conversation_id].to_s,
      input: params[:text].to_s,
      metadata: { "whatsapp" => { "conversation_id" => params[:conversation_id] } }
    )
    head :ok
  rescue Silas::TurnInProgressError
    head :ok
  end
end

dispatch is the whole inbound API. It maps the external thread to a session and then calls the ordinary public API — Silas.agent.start for a new thread, session.continue for a reply. There is no channel-specific loop.

Three things to get right:

The thread key must be stable. The same conversation must produce the same key every time, or every message starts a new session and the agent has no memory. Use the vendor’s conversation or thread id, never a per-message id. Keys are namespaced by channel (whatsapp:abc123), so two channels can’t collide.

Sign over the raw body. request.raw_post, never re-serialized params — re-serializing changes the bytes and the HMAC no longer matches. See Verification below.

One active turn per session is an invariant, not a queue. A reply that arrives while the agent is still working raises Silas::TurnInProgressError. The generated controller answers 200 so the vendor stops retrying; if dropping the message matters for your transport, buffer it or tell the user.

Metadata is stored on the session and shown in the inbox, so keep it small and free of secrets. It exists so outbound knows where to reply.

Verification

Silas::Webhook.verify_hmac handles the parts that are identical everywhere — constant-time comparison, the replay window, and failing closed when no secret is configured. You supply the vendor’s shape:

Silas::Webhook.verify_hmac(
  secret:    Rails.application.credentials.dig(:silas, :whatsapp, :signing_secret),
  signature: request.headers["X-Signature"],
  payload:   request.raw_post,   # what they signed
  timestamp: request.headers["X-Signature-Timestamp"],
  prefix:    "sha256=",          # what they put before the digest
  digest:    :hex                # :hex, or :base64 for Shopify/Twilio
)

Vendors differ only in three places:

Vendor payload prefix digest
Slack "v0:#{timestamp}:#{body}" "v0=" :hex
GitHub raw body "sha256=" :hex
Shopify raw body "" :base64

Omitting timestamp: disables replay protection — a captured request can then be re-sent forever. If your vendor doesn’t send one, that’s worth knowing rather than defaulting past.


Outbound: answers and approvals

class Agent::Channels::Whatsapp < Silas::Channel
  def deliver_answer(session:, text:)
    # post `text` back to session.metadata["whatsapp"]
  end

  def deliver_approval(session:, invocation:)
    approve = Silas::Channel.approval_url(invocation, :approve)
    decline = Silas::Channel.approval_url(invocation, :decline)
    # send those two links to an OPERATOR
  end
end

You never call these. ChannelDeliveryJob does, from after_commit callbacks — deliver_answer when a turn completes or fails, deliver_approval the moment a tool parks. Both run off the durable loop, which is the important part: a transport outage retries the delivery without re-running a single tool or touching the ledger. Raising here is safe. It costs a retry, not a duplicate side effect.

Delivery is claimed compare-and-swap on a marker column (answered_at, notified_at) and released if your method raises, so a retry re-attempts rather than double-sending. A duplicate ping is the worst case; it is never a ledger violation.

Silas::Channel.approval_url mints a signed, expiring one-click link that works in any transport — a WhatsApp message, a Discord embed, an SMS. Possession of the link is the credential, so it needs no session and no login, and it expires with config.approval_ttl (7 days by default).

It needs a host, and never guesses one — a hostless approval link is a dead link:

config.action_mailer.default_url_options = { host: "yourapp.com" }

Send approvals to an operator, never to the requester. For a customer-facing channel, session.metadata describes the person who messaged in. Sending them the approve link lets them approve their own refund. Deliver to your ops destination instead, and fail closed when it isn’t configured — no destination means no approval, not a silent one. The generated channel does this; keep it.

Slack is different only because it can do better: it renders real Approve/Decline buttons via Block Kit, handled by the engine’s own actions webhook. Every other transport uses the signed link.


What you get for free

Everything that makes the agent durable is already there, because a channel is a trigger, not a runtime:

  • Crash-safe turns. Kill the worker mid-conversation; the turn resumes where it stopped, and no tool runs twice.
  • Approvals that park at zero compute. A parked turn holds no worker, no memory, no tokens. It can wait days.
  • The inbox. Every channel session appears at /silas/inbox with its full trace — you can watch and approve a WhatsApp conversation from the operator UI without building anything.
  • Budgets, cancellation, evals, memory. All unchanged.

A channel is also not in the definitions digest — it’s a transport, not a model-visible capability — so adding or changing one never fails a parked turn with NondeterminismError.


Worked example: WhatsApp Cloud API

bin/rails generate silas:channel whatsapp
bin/rails credentials:edit
silas:
  whatsapp:
    signing_secret: <your app secret>
    operator: "+441234567890"
    token: <your permanent access token>
    phone_number_id: "123456789"

Meta signs the raw body with your app secret and prefixes sha256=:

# app/controllers/agent/channels/whatsapp_controller.rb
def verify_webhook!
  ok = Silas::Webhook.verify_hmac(
    secret: Rails.application.credentials.dig(:silas, :whatsapp, :signing_secret),
    signature: request.headers["X-Hub-Signature-256"],
    payload: request.raw_post,
    prefix: "sha256="   # Meta sends no timestamp header: no replay window
  )
  head(:unauthorized) unless ok
  ok
end

def create
  message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
  return head(:ok) unless message && message[:type] == "text"

  Agent::Channels::Whatsapp.dispatch(
    thread_key: message[:from],            # the sender's number: stable per conversation
    input: message.dig(:text, :body).to_s,
    metadata: { "whatsapp" => { "to" => message[:from] } }
  )
  head :ok
rescue Silas::TurnInProgressError
  head :ok
end
# app/agent/channels/whatsapp.rb
class Agent::Channels::Whatsapp < Silas::Channel
  def deliver_answer(session:, text:)
    send_text(to: session.metadata.dig("whatsapp", "to"), body: text)
  end

  def deliver_approval(session:, invocation:)
    operator = Rails.application.credentials.dig(:silas, :whatsapp, :operator)
    if operator.blank?
      Rails.logger.warn("[Silas] no operator configured — approval #{invocation.id} not delivered")
      return
    end

    send_text(to: operator, body: <<~MSG)
      Approval needed: #{invocation.tool_name}
      #{JSON.pretty_generate(invocation.arguments)}

      Approve: #{Silas::Channel.approval_url(invocation, :approve)}
      Decline: #{Silas::Channel.approval_url(invocation, :decline)}
    MSG
  end

  private

  def send_text(to:, body:)
    credentials = Rails.application.credentials.silas[:whatsapp]
    uri = URI("https://graph.facebook.com/v20.0/#{credentials[:phone_number_id]}/messages")
    response = Net::HTTP.post(
      uri,
      { messaging_product: "whatsapp", to: to, type: "text", text: { body: body } }.to_json,
      "content-type" => "application/json",
      "authorization" => "Bearer #{credentials[:token]}"
    )
    # Raise on failure: ChannelDeliveryJob releases its claim and retries.
    raise Silas::Error, "WhatsApp send failed: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
  end
end

Point Meta’s webhook at https://yourapp.com/agent/channels/whatsapp and restart — app/agent/ registers at boot.


Why Silas ships one channel and a generator

Slack is first-party because it ships a whole dialect: signature scheme, Block Kit approvals, an interactive-actions webhook. Every other transport is a different vendor’s API drifting on its own schedule, and a framework that promises to keep six of those working is a framework that breaks.

The generator is the leverage instead. It gets the security decisions right — signature-first, raw body, fail closed, operators not requesters — and leaves the vendor’s three variables to you.

If you build one worth sharing, open a PR against this page.


This site uses Just the Docs, a documentation theme for Jekyll.