Install Leo SMS Gateway | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Install Leo SMS Gateway

A two-way SMS/MMS gateway your app owns — inbound Twilio webhook to database rows, a threaded inbox UI, outbound send with delivery receipts, group texts — plus the Codex CLI /goal loop that checks the text inbox (and Gmail) every 10 minutes, sleeps itself between ticks, and asks a human before answering anyone.

Proven on llamapress.ai model · controller · view

Install Leo SMS Gateway

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

Sending a text is easy. Owning a conversation is the hard part: a phone number that receives, a webhook that turns replies into rows, threads a human can read, delivery receipts that tell you what actually arrived, and — the reason this guide exists — an agent that checks that inbox on a schedule without you sitting there.

This recipe installs the gateway, then wires it to a Codex CLI session driven by /goal that wakes every 10 minutes, reads the text inbox (and the Gmail inbox, if you also installed Install Agent Gmail Service), investigates anything new, texts you a short summary with a recommendation, and sends nothing to anyone until you reply.

When to use: you want a phone number that behaves like a shared inbox — customer replies, an on-call channel, an agent that watches for messages while you sleep. When not to: one-off outbound alerts with no reply path (the Twilio cookbook is enough), or marketing blasts to a large list (carrier A2P 10DLC registration is its own project).

Gem check — nothing to install. twilio-ruby is already in the Leo base image (verified 7.10.7 on llamapress-simple:0.7.2). You cannot add gems on a Leo box and you do not need to:

docker compose exec -T llamapress bash -c "bundle list | grep twilio"

The 80/20 in one breath

  1. Buy a +1 long code in the Twilio Console and note it. Set TWILIO_SID, TWILIO_AUTH, TWILIO_ACCOUNT_SID in .env, then docker compose up -d --force-recreate llamapress (a restart does not reload .env).
  2. Create the sms_messages table and run the migration immediately.
  3. Copy SmsMessage, SmsGateway, and Api::TwilioSmsController in; add the three webhook routes and the four inbox routes.
  4. Paste SmsGateway.webhook_url into the number's "A message comes in" webhook field in the Twilio Console (HTTP POST).
  5. Text the number from your phone. A row appears; /inbox shows the thread.
  6. Install Codex CLI on the box, open one session, and type the /goal from Layer 7. The loop starts.

Layer 1 — Migration

# db/migrate/20260820000001_create_sms_messages.rb
class CreateSmsMessages < ActiveRecord::Migration[7.2]
  def change
    create_table :sms_messages do |t|
      t.string :direction, null: false     # "inbound" | "outbound"
      t.string :from_number, null: false
      t.string :to_number, null: false

      # THE THREAD KEY: the external party's number regardless of direction, digit
      # normalized (+1801...), so one indexed column groups a whole conversation.
      t.string :counterpart_number, null: false

      t.text   :body
      t.string :twilio_sid
      t.string :status                     # queued/sent/delivered/failed out; "received" in
      t.string :error_message

      t.integer :num_media, null: false, default: 0
      t.jsonb   :media_urls, null: false, default: []

      t.datetime :read_at                  # a human saw it in the inbox
      t.jsonb    :metadata, null: false, default: {}

      # Group threads (Twilio Conversations). For group rows counterpart_number holds
      # the CH... conversation SID, so thread grouping keeps working unchanged.
      t.string :conversation_sid
      t.string :author

      t.timestamps
    end

    # Twilio RETRIES on a slow response. This unique index makes a duplicate delivery
    # a no-op instead of a double row.
    add_index :sms_messages, :twilio_sid, unique: true
    add_index :sms_messages, [:counterpart_number, :created_at]
    add_index :sms_messages, :read_at
    add_index :sms_messages, :conversation_sid
  end
end
docker compose exec -T llamapress bin/rails db:migrate

Run it the moment you write it. A pending migration blocks every request in this stack, so leaving it until later takes the whole app down while you work.


Layer 2 — The model

# app/models/sms_message.rb
class SmsMessage < ApplicationRecord
  DIRECTIONS = %w[inbound outbound].freeze

  # The image on an OUTBOUND MMS. Inbound media stays on Twilio's CDN and is
  # referenced by URL in media_urls.
  has_one_attached :media_file

  validates :direction, inclusion: { in: DIRECTIONS }
  validates :from_number, :to_number, presence: true

  scope :inbound,      -> { where(direction: "inbound") }
  scope :outbound,     -> { where(direction: "outbound") }
  scope :unread,       -> { inbound.where(read_at: nil) }
  scope :with_number,  ->(n) { where(counterpart_number: normalize_thread_key(n)) }

  before_validation :set_counterpart_number

  # "+1 (801) 555-0100", "8015550100", "18015550100" all -> "+18015550100".
  def self.normalize_number(raw)
    digits = raw.to_s.gsub(/\D/, "")
    return raw.to_s if digits.blank?
    digits = "1#{digits}" if digits.length == 10
    "+#{digits}"
  end

  # A thread key is either a phone number or a group conversation SID (CH...).
  # ALWAYS use this on a value that might be either — normalize_number strips the
  # letters out of a CH sid and silently destroys it.
  def self.normalize_thread_key(key)
    key.to_s.start_with?("CH") ? key.to_s : normalize_number(key)
  end

  def inbound? = direction == "inbound"
  def group?   = conversation_sid.present?

  # Latest message per counterpart, newest conversation first.
  def self.conversations
    latest = select("DISTINCT ON (counterpart_number) id").order(:counterpart_number, created_at: :desc)
    where(id: latest).order(created_at: :desc)
  end

  def self.unread_counts = unread.group(:counterpart_number).count

  # The user this number belongs to, if any. Phone columns are free text, so match
  # on the last 10 digits.
  def matched_user
    digits = counterpart_number.to_s.gsub(/\D/, "").last(10)
    return nil if digits.blank?
    User.where("regexp_replace(phone, '\\D', '', 'g') LIKE ?", "%#{digits}").first
  end

  private

  def set_counterpart_number
    external = inbound? ? from_number : to_number
    self.counterpart_number = conversation_sid.presence || self.class.normalize_number(external)
  end
end

Layer 3 — SmsGateway (all send and receive logic)

Every message in or out goes through this one module, and every message becomes a row — including failures.

# app/services/sms_gateway.rb
module SmsGateway
  # The number lives in CODE, not .env, on purpose: this file hot-reloads on save,
  # while a new .env var needs a container recreate (= downtime).
  INBOX_NUMBER = "+18015550100"

  # Public base URL of this app, used to build webhook URLs and MMS media links.
  APP_HOST = ENV.fetch("APP_HOST", "https://yourapp.example.com")

  # A restricted Twilio API key cannot sign-verify X-Twilio-Signature (that needs the
  # account auth token). So the webhook URL carries a secret path token instead,
  # derived from secret_key_base — nothing new to store, nothing new to rotate.
  # The controller also requires the posted AccountSid to match ours.
  def self.webhook_token
    OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, "twilio-sms-webhook")[0, 32]
  end

  def self.webhook_url               = "#{APP_HOST}/api/twilio/sms/#{webhook_token}"
  def self.status_callback_url       = "#{APP_HOST}/api/twilio/sms_status/#{webhook_token}"
  def self.conversations_webhook_url = "#{APP_HOST}/api/twilio/conversations/#{webhook_token}"

  # Sends an SMS/MMS and logs it. ALWAYS returns the row — on a Twilio failure the row
  # is saved with status "failed" and the error. Nothing raises out of here, so CHECK
  # the status; do not assume success.
  def self.send_sms(to:, body:, from: INBOX_NUMBER, media_upload: nil)
    to = SmsMessage.normalize_number(to)
    message = SmsMessage.new(direction: "outbound", from_number: from, to_number: to, body: body)

    if media_upload
      message.media_file.attach(media_upload)
      message.save!   # the blob must be persisted before it has a URL
      message.num_media = 1
      message.media_urls = [Rails.application.routes.url_helpers.rails_blob_url(
        message.media_file, host: APP_HOST)]
    end

    begin
      # twilio-ruby takes KEYWORD args. A positional hash raises ArgumentError — splat it.
      twilio_message = Twilio.get_client.messages.create(
        **{ from: from, to: to, body: body,
            status_callback: status_callback_url,
            media_url: message.media_urls.presence }.compact
      )
      message.twilio_sid = twilio_message.sid
      message.status     = twilio_message.status
    rescue => e
      message.status = "failed"
      message.error_message = "#{e.class}: #{e.message}"
    end

    message.save!
    message
  end

  # Called by the inbound webhook. Returns the created row.
  def self.receive(params)
    n = params["NumMedia"].to_i
    message = SmsMessage.create!(
      direction: "inbound",
      from_number: params["From"],
      to_number: params["To"],
      body: params["Body"],
      twilio_sid: params["MessageSid"],
      status: "received",
      num_media: n,
      media_urls: (0...n).map { |i| params["MediaUrl#{i}"] }.compact,
      metadata: params.slice("FromCity", "FromState", "FromZip", "FromCountry", "SmsStatus")
                      .merge("media_content_types" => (0...n).map { |i| params["MediaContentType#{i}"] }.compact)
    )
    notify_owner(message)
    message
  end

  # Alert the owner that someone texted in. SMS truncates around 300 characters and
  # chops a trailing URL, so the reply link goes FIRST.
  def self.notify_owner(message)
    return unless ENV["SMS_INBOUND_ALERTS"] == "true"
    return if OWNER_NUMBERS.include?(message.from_number.to_s.delete("^0-9").last(10))

    thread_url = "#{APP_HOST}/inbox/#{message.counterpart_number.delete('+')}"
    OWNER_NUMBERS.each do |digits|
      send_sms(to: digits, body: "New text — reply: #{thread_url} — \"#{message.body.to_s.truncate(140)}\"")
    end
  rescue => e
    Rails.logger.error("SmsGateway.notify_owner failed: #{e.class}: #{e.message}")
  end

  OWNER_NUMBERS = %w[8015550199].freeze   # last 10 digits, no punctuation
end

Twilio.get_client comes from the Twilio cookbook — copy that module in first if you have not already.


Layer 4 — The webhook controller & routes

# app/controllers/api/twilio_sms_controller.rb
#
# Auth: the URL path carries a secret token, and the posted AccountSid must match ours.
# A bad token or AccountSid returns 404 — indistinguishable from a wrong route, so a
# prober learns nothing.
class Api::TwilioSmsController < ActionController::Base
  skip_before_action :verify_authenticity_token
  before_action :verify_webhook!

  # POST /api/twilio/sms/:token — inbound message.
  # Reply with EMPTY TwiML so Twilio does not auto-respond to the sender.
  def receive
    SmsGateway.receive(params.to_unsafe_h)
    render xml: '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
  rescue ActiveRecord::RecordNotUnique
    # Twilio retries on a slow response; the unique twilio_sid index makes it a no-op.
    render xml: '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
  end

  # POST /api/twilio/sms_status/:token — delivery receipt for an outbound send.
  def status
    SmsMessage.find_by(twilio_sid: params["MessageSid"])&.update(
      status: params["MessageStatus"],
      error_message: params["ErrorCode"].presence && "Twilio error #{params['ErrorCode']}"
    )
    head :ok
  end

  # POST /api/twilio/conversations/:token — group message added (Layer 6).
  def conversation_event
    SmsGateway.receive_group_event(params.to_unsafe_h)
    head :ok
  rescue ActiveRecord::RecordNotUnique
    head :ok
  end

  private

  def verify_webhook!
    token_ok = ActiveSupport::SecurityUtils.secure_compare(params[:token].to_s, SmsGateway.webhook_token)
    account_ok = action_name == "conversation_event" ||
                 (params["AccountSid"].present? && params["AccountSid"] == ENV["TWILIO_ACCOUNT_SID"])
    head :not_found unless token_ok && account_ok
  end
end
# config/routes.rb
post 'api/twilio/sms/:token',           to: 'api/twilio_sms#receive'
post 'api/twilio/sms_status/:token',    to: 'api/twilio_sms#status'
post 'api/twilio/conversations/:token', to: 'api/twilio_sms#conversation_event'

get  '/inbox',          to: 'inbox#index',  as: :inbox_index
post '/inbox',          to: 'inbox#create'
get  '/inbox/:number',  to: 'inbox#show',   as: :inbox_thread
post '/inbox/groups',   to: 'inbox#create_group', as: :inbox_create_group

Get the live webhook URL and paste it into the Twilio Console:

docker compose exec -T llamapress sh -c \
  'bin/rails runner "File.write(%q{/tmp/u}, SmsGateway.webhook_url)" >/dev/null 2>&1; cat /tmp/u'

Layer 5 — The inbox UI

Two screens. The job of the index is one question: are there new texts, and from whom? So layer 1 of the page carries five things and nothing more — who, the newest snippet, when, an amber "needs you" badge, and a group marker.

# app/controllers/inbox_controller.rb
class InboxController < ApplicationController
  before_action :authenticate_user!

  def index
    @conversations = SmsMessage.conversations.limit(200)
    @unread_counts = SmsMessage.unread_counts
  end

  # Viewing a thread marks its inbound messages read.
  def show
    @number   = SmsMessage.normalize_thread_key(params[:number])
    @messages = SmsMessage.with_number(@number).order(:created_at)
    raise ActiveRecord::RecordNotFound if @messages.empty?
    SmsMessage.with_number(@number).unread.update_all(read_at: Time.current)
  end

  def create
    to, body, media = params[:to].to_s, params[:body].to_s.strip, params[:media]

    if to.gsub(/\D/, "").length < 10 || (body.blank? && media.blank?)
      return redirect_back fallback_location: inbox_index_path,
                           alert: "Need a 10-digit number and a message or image."
    end

    message = SmsGateway.send_sms(to: to, body: body, media_upload: media)
    path = inbox_thread_path(number: message.counterpart_number.delete("+"))
    if message.status == "failed"
      redirect_to path, alert: "Send failed: #{message.error_message}"
    else
      redirect_to path, notice: "Message sent."
    end
  end
end
<%# app/views/inbox/index.html.erb
    JOB: someone checking texts decides who wrote in and what to reply.
    3-SEC Q: are there new incoming texts, and from whom?
    L1: name/number · newest snippet · time · amber unread badge. L2: the thread page. %>
<div class="w-full max-w-4xl mx-auto px-4 py-6">
  <h1 class="font-bold text-3xl text-slate-900">Text Inbox</h1>
  <p class="text-sm text-slate-600 mt-1">
    Messages to and from <span class="font-mono"><%= SmsGateway::INBOX_NUMBER %></span>.
  </p>

  <div class="mt-6 bg-white rounded-lg shadow border border-slate-200 divide-y divide-slate-100">
    <% if @conversations.empty? %>
      <div class="px-4 py-10 text-center text-slate-400">No messages yet.</div>
    <% end %>

    <% @conversations.each do |message| %>
      <% unread = @unread_counts[message.counterpart_number].to_i %>
      <%= link_to inbox_thread_path(number: message.counterpart_number.delete("+")),
                  class: "flex items-center gap-4 px-4 py-3 hover:bg-slate-50" do %>
        <div class="flex-1 min-w-0">
          <div class="flex items-center gap-2">
            <span class="font-mono text-sm <%= unread.positive? ? 'font-bold text-slate-900' : 'text-slate-700' %>">
              <%= "👥 " if message.group? %><%= message.counterpart_number %>
            </span>
            <% if unread.positive? %>
              <span class="px-2 py-0.5 text-xs font-semibold bg-amber-100 text-amber-800 rounded-full">
                <%= unread %> new
              </span>
            <% end %>
          </div>
          <div class="text-sm text-slate-500 truncate mt-0.5">
            <%= message.inbound? ? "" : "You: " %><%= message.body.presence&.truncate(90) %>
          </div>
        </div>
        <div class="text-xs text-slate-400 whitespace-nowrap">
          <%= time_ago_in_words(message.created_at) %> ago
        </div>
      <% end %>
    <% end %>
  </div>
</div>

The thread page is a transcript: inbound messages left and light, outbound right and tinted, a reply box pinned at the bottom. Position carries the "theirs vs ours" distinction; colour is reserved for the one thing that needs a human — the unread badge.


Layer 6 — Group texts (optional)

A group thread lets the agent ask two people a question in one message and read both replies in one place. It is a Twilio Conversations conversation: the external numbers join as SMS participants, you join as an identity projected from your inbox number, and a conversation-scoped webhook delivers replies.

# app/services/sms_gateway.rb  (continued)
module SmsGateway
  CONVERSATIONS_IDENTITY = "app"

  def self.create_group_conversation(numbers:, name: nil)
    numbers = numbers.map { |n| SmsMessage.normalize_number(n) }
    client  = Twilio.get_client
    convo   = client.conversations.v1.conversations.create(
      friendly_name: name.presence || "Group with #{numbers.join(', ')}")

    numbers.each do |n|
      client.conversations.v1.conversations(convo.sid)
            .participants.create(messaging_binding_address: n)
    end
    client.conversations.v1.conversations(convo.sid).participants.create(
      identity: CONVERSATIONS_IDENTITY, messaging_binding_projected_address: INBOX_NUMBER)

    # Conversation-SCOPED webhook: a restricted API key usually lacks the grant to
    # register a global one.
    client.conversations.v1.conversations(convo.sid).webhooks.create(
      target: "webhook", configuration_url: conversations_webhook_url,
      configuration_filters: ["onMessageAdded"], configuration_method: "POST")

    SmsMessage.create!(direction: "outbound", from_number: INBOX_NUMBER, to_number: convo.sid,
                       conversation_sid: convo.sid, author: CONVERSATIONS_IDENTITY,
                       body: "(group created: #{numbers.join(', ')})", status: "note",
                       metadata: { "group_name" => name.presence, "participants" => numbers })
  end

  def self.send_group_message(conversation_sid:, body:)
    message = SmsMessage.new(direction: "outbound", from_number: INBOX_NUMBER,
                             to_number: conversation_sid, conversation_sid: conversation_sid,
                             author: CONVERSATIONS_IDENTITY, body: body)
    begin
      tm = Twilio.get_client.conversations.v1.conversations(conversation_sid)
                 .messages.create(author: CONVERSATIONS_IDENTITY, body: body)
      message.twilio_sid = tm.sid
      message.status     = "sent"
    rescue => e
      message.status = "failed"
      message.error_message = "#{e.class}: #{e.message}"
    end
    message.save!
    message
  end

  # Our OWN authored messages echo back through this webhook — drop them.
  def self.receive_group_event(params)
    return nil unless params["EventType"] == "onMessageAdded"
    return nil if params["Author"] == CONVERSATIONS_IDENTITY

    media = params["Media"].present? ? JSON.parse(params["Media"]) : []
    SmsMessage.create!(direction: "inbound", from_number: params["Author"],
                       to_number: INBOX_NUMBER, conversation_sid: params["ConversationSid"],
                       author: params["Author"], twilio_sid: params["MessageSid"],
                       status: "received", body: params["Body"], num_media: media.length,
                       metadata: { "conversation_media" => media })
  end
end

Group limits, all Twilio's, all deliberate: at most 10 participants; +1 long codes only (toll-free and short codes cannot group text); text-only sends unless you build Twilio's separate media upload flow; and the group must start from your side unless you configure address auto-creation.


Layer 7 — The agent loop: Codex CLI + /goal

Everything above is a gateway. This is what makes it staffed.

The design in one sentence: one Codex session holds a standing goal; each turn does one sweep of the inboxes and then sleeps ~10 minutes; when the turn ends and the thread goes idle, Codex automatically continues the goal, which starts the next tick. The goal is the loop's engine. The sleep is its clock.

7a. Install and sign in

node and npm are already on a Leo box; codex is not.

npm install -g @openai/codex
codex login          # device-flow: it prints a URL, you authorize in a browser
codex --version

Confirm the goals feature is on (it is stable and on by default):

codex features list | grep goals      # -> goals  stable  true

7b. Give the agent the facts (AGENTS.md)

Codex reads AGENTS.md from the working directory. Put the things it would otherwise guess wrong there — once — so the goal itself stays short.

<!-- AGENTS.md (at the repo root) -->
# Repository Guidelines

## Running Rails
Ruby is not on the host. Every Rails command runs in the container:
`docker compose exec -T llamapress bin/rails <cmd>`

Throwaway runner scripts go in `rails/db/scripts/` and run as
`bin/rails runner db/scripts/<name>.rb`. Do NOT write them to `rails/` — only named
subdirectories of `rails/` are bind-mounted, so a file at `rails/foo.rb` does not exist
inside the container.

## The inboxes
- Text: `SmsGateway` / `SmsMessage`, inbox UI at `/inbox`.
- Email: `AgentGmailTools` (draft-first — it cannot send without `confirm: true`).

## Standing rules
- Never send a text or an email to anyone outside the owner group without an explicit
  human approval in the owner thread.
- Never commit. Leave changes in the working tree.

7c. The tick script (the procedure lives in code, not in the prompt)

A prose procedure rots silently; a script is tested against reality every run. So the data gathering is a script and only the judgement is the agent's.

# rails/db/scripts/agent_inbox_tick.rb
# One sweep of both inboxes -> /tmp/agent_inbox_tick.json (inside the container).
# rails/db is always bind-mounted (migrations need it) and Zeitwerk never autoloads db/,
# so this is the one location that works on every box.
require "json"

last_sms = ENV["LAST_SMS_ID"].to_i

sms = SmsMessage.inbound.where("id > ?", last_sms).order(:id).map do |m|
  { id: m.id, from: m.from_number, thread: m.counterpart_number, group: m.group?,
    body: m.body.to_s[0, 500], media: m.num_media, at: m.created_at.utc.iso8601 }
end

# Email is OPTIONAL: only swept if the Gmail service is installed and connected.
email = []
if defined?(AgentGmailTools) && AgentGmailTools.connected_mailboxes.any?
  AgentGmailTools.connected_mailboxes.each_key do |box|
    tools = AgentGmailTools.for(box)
    # newer_than:1h overlaps a 10-minute tick generously, so a swallowed tick or a slow
    # Gmail index cannot drop a message. in:anywhere because real mail lands in spam.
    tools.search_emails(query: "in:anywhere newer_than:1h -in:sent", max_results: 25).each do |hit|
      m = tools.read_email(message_id: hit[:id])
      email << { mailbox: box, id: m[:id], from: m[:from], subject: m[:subject],
                 at: m[:date], snippet: m[:snippet].to_s[0, 300] }
    end
  end
end

File.write("/tmp/agent_inbox_tick.json", JSON.pretty_generate(
  generated_at: Time.now.utc.iso8601,
  last_sms_id_seen: last_sms,
  max_sms_id: SmsMessage.maximum(:id),
  new_sms: sms,
  recent_email: email
))
#!/usr/bin/env bash
# bin/agent-inbox-tick.sh — gather one tick into tmp/agent_inbox/tick.json on the host.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p tmp/agent_inbox

STATE=tmp/agent_inbox/state.json
LAST_SMS=$(python3 -c "import json,sys,os; p='$STATE'; print(json.load(open(p)).get('last_sms_id',0) if os.path.exists(p) else 0)")

# Write the result to a file INSIDE the container, then cat it out. Never parse the
# runner's stdout directly — this stack prints an auth token to stdout on boot, which
# would land in your JSON and in the agent's context.
docker compose exec -T -e LAST_SMS_ID="$LAST_SMS" llamapress \
  sh -c 'bin/rails runner db/scripts/agent_inbox_tick.rb >/dev/null 2>&1; cat /tmp/agent_inbox_tick.json' \
  > tmp/agent_inbox/tick.json

echo "--- tick $(date -u +%FT%TZ) (since sms id $LAST_SMS) ---"
cat tmp/agent_inbox/tick.json
chmod +x bin/agent-inbox-tick.sh
./bin/agent-inbox-tick.sh          # run it once by hand before you hand it to an agent

7d. The /goal itself

Start Codex in the project directory, then type /goal followed by the objective. Codex stores it against the thread; a long objective is written to a file for you, so length is fine.

codex
/goal Staff the text inbox on a 10-minute loop, indefinitely. Repeat this tick forever; this objective is never complete, so do not call update_goal.

EACH TICK, in order:
1. Run ./bin/agent-inbox-tick.sh and read tmp/agent_inbox/tick.json.
2. For each new_sms entry and each recent_email entry not already in seen_ids in tmp/agent_inbox/state.json, decide: real message, or noise (delivery receipts, our own alerts, automated mail)? Dedup email on the tuple sender + subject + timestamp, NOT on id — the same email has a different id in every mailbox it reached.
3. INVESTIGATE before you write anything. Read the full message, look up the sender, check the logs if they report a fault. One recommended action per issue.
4. Text me a summary in the owner thread: at most 3 texts per tick, each under 300 characters, link first. Use SmsGateway.send_sms (or send_group_message for the owner group). Format: who + what + your recommendation + "OK to proceed?".
5. Send NOTHING to the person who wrote in until I reply in the owner thread approving it. Drafting an unsent reply while you wait is encouraged. An email reply must go through AgentGmailTools with reply_to_message_id, and only with confirm: true once I have approved.
6. Check for my replies: SmsMessage.inbound rows newer than last_owner_reply_id in the state file. Act on a decision, then confirm back in ONE short text.
7. Write tmp/agent_inbox/state.json with last_sms_id (use max_sms_id from the tick), last_owner_reply_id, seen_ids, pending_asks, and last_tick_at set to now in UTC ISO8601. Write it EVERY tick, including quiet ticks — last_tick_at is the heartbeat a watchdog reads.
8. Sleep until the next tick: run `sleep 600` as a single shell command with a tool timeout of at least 660000 ms. Then end the turn.

Never commit. Never text or email anyone outside the owner group without my explicit approval in the owner thread.

Then confirm the goal took, and manage it:

Command What it does
/goal <objective> Sets the standing objective for this thread
/goal Shows the current goal, its status, and usage so far
/goal pause Stops the automatic continuations — the loop halts, the goal survives
/goal resume Restarts the loop from where it stopped
/goal edit Change the objective without losing the thread
/goal clear Ends the goal for good

A goal is active, paused, blocked, usage_limited, budget_limited, or complete. Only pause and resume are yours — the agent can only mark a goal complete or blocked, and it is instructed not to claim blocked until the same obstacle has recurred across three consecutive turns.

7e. Why the loop keeps going

When a turn ends and the thread has an active goal, Codex issues its own continuation turn — "Continue working toward the active thread goal" — with the objective and the remaining budget attached. That continuation is what starts the next tick. The sleep 600 at the end of step 8 is what stops those continuations from firing back to back and burning your budget in an afternoon.

Prove the cadence before you trust it. Let it run twice, then read the timestamps:

python3 -c "import json;print(json.load(open('tmp/agent_inbox/state.json'))['last_tick_at'])"

Two ticks about ten minutes apart means the sleep survived. If they are seconds apart, the sleep was cut short by the shell tool's timeout — go to 7f, which does not depend on the sleep at all.

7f. The backstop: a cron tick and a dead-man's switch

A session-driven loop dies with its session, silently. Close the terminal, lose the SSH connection, reboot the box — the texts simply stop, and nothing tells you. Two lines of defence, and you want both:

# Time-driven tick. Resumes the SAME thread, so the goal and its history carry over.
*/10 * * * * /home/ubuntu/.local/bin/codex exec resume --last \
  "Run one tick of the standing inbox goal now." >> log/agent-inbox-cron.log 2>&1

# Dead-man's switch: if last_tick_at goes stale, text the owner once.
*/5 * * * * /home/ubuntu/Leonardo/bin/agent-loop-watchdog.sh
#!/usr/bin/env bash
# bin/agent-loop-watchdog.sh — a dead loop cannot text you about its own death.
set -euo pipefail
cd "$(dirname "$0")/.."
STATE=tmp/agent_inbox/state.json
SENTINEL=tmp/agent_inbox/watchdog.alerted
[ -f tmp/agent_inbox/watchdog.disarm ] && exit 0     # deliberate stop

AGE=$(python3 - "$STATE" <<'PY'
import json, sys, os, datetime
p = sys.argv[1]
if not os.path.exists(p): print(999999); raise SystemExit
t = json.load(open(p)).get("last_tick_at")
last = datetime.datetime.fromisoformat(t.replace("Z", "+00:00"))
print(int((datetime.datetime.now(datetime.timezone.utc) - last).total_seconds() / 60))
PY
)

if [ "$AGE" -gt 35 ] && [ ! -f "$SENTINEL" ]; then
  docker compose exec -T llamapress sh -c \
    "bin/rails runner \"SmsGateway.send_sms(to: SmsGateway::OWNER_NUMBERS.first, body: 'Inbox loop looks DEAD — last tick ${AGE} min ago.')\" >/dev/null 2>&1"
  touch "$SENTINEL"
elif [ "$AGE" -le 35 ] && [ -f "$SENTINEL" ]; then
  rm -f "$SENTINEL"                                   # recovered
fi

35 minutes, not 15, on purpose. Interactive use swallows ticks: a continuation only fires while the session is idle, so any command you type in that session skips that tick. A 20-minute gap is normal, not a fault.

Stopping the loop deliberately? touch tmp/agent_inbox/watchdog.disarm first, or the watchdog texts you a false death alert. Delete it when you restart.


Gotchas (the hard-won stuff)

The gateway

  1. twilio-ruby's messages.create takes keyword arguments only. A positional hash — create({...}.compact) — fails with ArgumentError: wrong number of arguments (given 1, expected 0). Splat it: create(**hash).

  2. An outbound MMS media URL 403s on HEAD and 200s on GET with Active Storage's disk service. Twilio uses GET, so it works. Do not "verify" with curl -I and conclude the link is broken.

  3. Always use normalize_thread_key, never normalize_number, on a value that might be a group SID. normalize_number strips the letters out of CH... and silently destroys the key.

  4. A restricted Twilio API key cannot manage phone numbers (listing, buying, or editing a number's webhook returns 401, error 70051), and often cannot register a global Conversations webhook. Set the number's webhook by hand in the Console; scope the Conversations webhook per conversation as Layer 6 does.

  5. Signature validation needs the account auth token, which a restricted key is not. That is why the secret is in the URL path. If you do hold the auth token, prefer Twilio's real X-Twilio-Signature validation and keep the AccountSid check as a belt.

  6. .env changes need a container recreate, not a restart: docker compose up -d --force-recreate llamapress. This is exactly why INBOX_NUMBER is a constant in a hot-reloading file instead of an env var.

  7. config/routes.rb is often a single-FILE bind mount. An atomic-write editor replaces the host file's inode and detaches it from the mount: the host file updates, the running app keeps reading the old one, and your route silently never registers. After editing, rewrite it in place inside the container and verify: docker compose exec -T llamapress sh -c 'cat > /rails/config/routes.rb' < rails/config/routes.rb then bin/rails routes | grep twilio.

  8. Test the webhook without a phone. Post to your own endpoint — it exercises the real stack, including the alert path, so use an obviously fake body:

    TOK=$(docker compose exec -T llamapress sh -c \
      'bin/rails runner "File.write(%q{/tmp/t}, SmsGateway.webhook_token)" >/dev/null 2>&1; cat /tmp/t')
    curl -s -X POST "http://127.0.0.1:3000/api/twilio/sms/$TOK" \
      --data-urlencode "AccountSid=$TWILIO_ACCOUNT_SID" \
      --data-urlencode "MessageSid=SMtest$(date +%s)" \
      --data-urlencode "From=+15005550006" --data-urlencode "To=+18015550100" \
      --data-urlencode "NumMedia=0" --data-urlencode "Body=webhook test"
    

The loop

  1. Write throwaway runner scripts to rails/db/scripts/, never to rails/. Only named subdirectories of rails/ are bind-mounted, so rails/tick.rb on the host does not exist at /rails/tick.rb in the container and rails runner answers "could not be found". rails/db is always mounted, because migrations need it.
  2. Never parse rails runner stdout. This stack prints an auth token to stdout at boot. Write results to a file inside the container and cat that file — otherwise the token lands in your JSON, your logs, and the agent's context window.
  3. A token budget silently ends the loop. If you give the goal a budget, exhausting it flips the status to budget_limited, and the agent is instructed to wrap up rather than start new work. For a standing loop, either omit the budget or check /goal occasionally. usage_limited means your account hit its rate limit — same effect, different cause.
  4. The goal survives a resume, and only a resume. codex resume --last restores the thread's goal and the loop picks up. Starting a fresh codex session does not — it has no goal, so it sits there while you assume it is working. This is also why the cron backstop uses codex exec resume --last rather than plain codex exec.
  5. The agent may not mark the goal complete for you. A standing loop has no end state, so say so in the objective ("this objective is never complete, do not call update_goal"). Without that line an agent that finishes a quiet tick may reasonably decide the work is done and stop the loop.
  6. Dedup email on sender + subject + timestamp, not on message id. The same email has a different id in every mailbox it reached, so a customer who mails one address and copies another appears twice with two unrelated ids. Record every per-mailbox id you saw, but decide with the tuple.
  7. A quiet tick still writes the state file. last_tick_at is the heartbeat. Skipping it when nothing happened makes the watchdog cry wolf every night.
  8. "Nobody replied" is only provable for connected mailboxes. If mail went to an address you are not connected to, someone may have answered it invisibly. Have the agent say "I see no reply in the mailboxes I can read", never "nobody answered".

Files this pattern touches

db/migrate/20260820000001_create_sms_messages.rb
app/models/sms_message.rb
app/services/sms_gateway.rb
app/services/twilio.rb                     # from the Twilio cookbook
app/controllers/api/twilio_sms_controller.rb
app/controllers/inbox_controller.rb
app/views/inbox/index.html.erb
app/views/inbox/show.html.erb
config/routes.rb
.env                                       # TWILIO_SID, TWILIO_AUTH, TWILIO_ACCOUNT_SID

AGENTS.md                                  # what Codex reads on every turn
bin/agent-inbox-tick.sh                    # one sweep -> tmp/agent_inbox/tick.json
bin/agent-loop-watchdog.sh                 # dead-man's switch
rails/db/scripts/agent_inbox_tick.rb       # the runner half of the sweep
tmp/agent_inbox/state.json                 # cursors, seen ids, pending asks, heartbeat

How to adapt to your schema

  1. Swap the owner channel. OWNER_NUMBERS and SmsGateway::INBOX_NUMBER are the only two constants carrying your phone numbers. If you would rather be asked by email than by text, keep everything else and have step 4 of the goal draft an email through AgentGmailTools instead.
  2. No User model? Delete matched_user. Nothing else in the gateway needs one.
  3. Change the cadence in exactly two places: the sleep in step 8 of the goal, and the cron expression. Keep the watchdog threshold at roughly three times the tick, or swallowed ticks will page you.
  4. Different approval rule? The gate is one sentence of the objective (step 5). Loosen it to "reply directly to questions answerable from the wiki, ask me about anything else" once you trust it — but change the sentence, not the code, so the rule stays in one place and stays readable.
  5. Safe to drop: Layer 6 (groups) if one owner is enough, MMS media if you only send text, and notify_owner once the agent loop is the thing telling you. Do not drop: the unique twilio_sid index (Twilio retries), the empty-TwiML response (Twilio auto-replies to the sender without it), or the state file (without it every tick re-reports the same messages).
  6. Using Claude Code instead of Codex? Everything except Layer 7d transfers unchanged — the tick script, the state file, and the watchdog are agent-agnostic. Swap /goal for that tool's own recurring-task mechanism and keep the same objective text.

Related