---
title: Install Leo SMS Gateway
slug: install-leo-sms-gateway
category: Integrations
summary: 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.
tags: [twilio, sms, mms, webhook, inbox, agent, codex, loop, integrations]
status: stable
visibility: public
source_project: llamapress.ai
layers: [model, controller, view]
related:
  - title: Install Agent Gmail Service
    url: /cookbook/install-agent-gmail-service
    summary: The email half. Set it up if you want the same loop to check a Gmail inbox as well as the text inbox.
  - title: Twilio — SMS Sending & Phone Verification
    url: /cookbook/twilio-sms-and-phone-verification
    summary: The layer underneath this one — credentials, the Twilio client, one-off sends, and phone verification.
  - title: Twilio Conversations API docs
    url: https://www.twilio.com/docs/conversations
    summary: Official reference for the group-messaging API used in Layer 6.
---

# 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](/cookbook/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](/cookbook/twilio-sms-and-phone-verification) 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:

```bash
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

```ruby
# 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
```

```bash
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

```ruby
# 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.

```ruby
# 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](/cookbook/twilio-sms-and-phone-verification) — copy that module in
first if you have not already.

---

## Layer 4 — The webhook controller & routes

```ruby
# 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
```

```ruby
# 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:

```bash
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.

```ruby
# 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
```

```erb
<%# 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.

```ruby
# 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.

```bash
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):

```bash
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.

```markdown
<!-- 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.

```ruby
# 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
))
```

```bash
#!/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
```

```bash
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:

```bash
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:

```cron
# 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
```

```bash
#!/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:

   ```bash
   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**

9. **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.
10. **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.
11. **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.
12. **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`.
13. **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.
14. **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.
15. **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.
16. **"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.
