{"slug":"install-leo-sms-gateway","meta":{"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 \u0026 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."}]},"body":"# Install Leo SMS Gateway\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nSending a text is easy. Owning a **conversation** is the hard part: a phone number that\nreceives, a webhook that turns replies into rows, threads a human can read, delivery\nreceipts that tell you what actually arrived, and — the reason this guide exists — an\nagent that checks that inbox on a schedule without you sitting there.\n\nThis recipe installs the gateway, then wires it to a **Codex CLI session driven by\n`/goal`** that wakes every 10 minutes, reads the text inbox (and the Gmail inbox, if you\nalso installed [Install Agent Gmail Service](/cookbook/install-agent-gmail-service)),\ninvestigates anything new, texts you a short summary with a recommendation, and **sends\nnothing to anyone until you reply**.\n\n\u003e **When to use:** you want a phone number that behaves like a shared inbox — customer\n\u003e replies, an on-call channel, an agent that watches for messages while you sleep.\n\u003e **When not to:** one-off outbound alerts with no reply path (the\n\u003e [Twilio cookbook](/cookbook/twilio-sms-and-phone-verification) is enough), or marketing\n\u003e blasts to a large list (carrier A2P 10DLC registration is its own project).\n\n**Gem check — nothing to install.** `twilio-ruby` is already in the Leo base image\n(verified `7.10.7` on `llamapress-simple:0.7.2`). You cannot add gems on a Leo box and\nyou do not need to:\n\n```bash\ndocker compose exec -T llamapress bash -c \"bundle list | grep twilio\"\n```\n\n---\n\n## The 80/20 in one breath\n\n1. Buy a **+1 long code** in the Twilio Console and note it. Set `TWILIO_SID`,\n   `TWILIO_AUTH`, `TWILIO_ACCOUNT_SID` in `.env`, then\n   `docker compose up -d --force-recreate llamapress` (a `restart` does **not** reload\n   `.env`).\n2. Create the `sms_messages` table and run the migration **immediately**.\n3. Copy `SmsMessage`, `SmsGateway`, and `Api::TwilioSmsController` in; add the three\n   webhook routes and the four inbox routes.\n4. Paste `SmsGateway.webhook_url` into the number's **\"A message comes in\"** webhook\n   field in the Twilio Console (`HTTP POST`).\n5. Text the number from your phone. A row appears; `/inbox` shows the thread.\n6. Install Codex CLI on the box, open one session, and type the `/goal` from Layer 7.\n   The loop starts.\n\n---\n\n## Layer 1 — Migration\n\n```ruby\n# db/migrate/20260820000001_create_sms_messages.rb\nclass CreateSmsMessages \u003c ActiveRecord::Migration[7.2]\n  def change\n    create_table :sms_messages do |t|\n      t.string :direction, null: false     # \"inbound\" | \"outbound\"\n      t.string :from_number, null: false\n      t.string :to_number, null: false\n\n      # THE THREAD KEY: the external party's number regardless of direction, digit\n      # normalized (+1801...), so one indexed column groups a whole conversation.\n      t.string :counterpart_number, null: false\n\n      t.text   :body\n      t.string :twilio_sid\n      t.string :status                     # queued/sent/delivered/failed out; \"received\" in\n      t.string :error_message\n\n      t.integer :num_media, null: false, default: 0\n      t.jsonb   :media_urls, null: false, default: []\n\n      t.datetime :read_at                  # a human saw it in the inbox\n      t.jsonb    :metadata, null: false, default: {}\n\n      # Group threads (Twilio Conversations). For group rows counterpart_number holds\n      # the CH... conversation SID, so thread grouping keeps working unchanged.\n      t.string :conversation_sid\n      t.string :author\n\n      t.timestamps\n    end\n\n    # Twilio RETRIES on a slow response. This unique index makes a duplicate delivery\n    # a no-op instead of a double row.\n    add_index :sms_messages, :twilio_sid, unique: true\n    add_index :sms_messages, [:counterpart_number, :created_at]\n    add_index :sms_messages, :read_at\n    add_index :sms_messages, :conversation_sid\n  end\nend\n```\n\n```bash\ndocker compose exec -T llamapress bin/rails db:migrate\n```\n\nRun it the moment you write it. A pending migration blocks **every** request in this\nstack, so leaving it until later takes the whole app down while you work.\n\n---\n\n## Layer 2 — The model\n\n```ruby\n# app/models/sms_message.rb\nclass SmsMessage \u003c ApplicationRecord\n  DIRECTIONS = %w[inbound outbound].freeze\n\n  # The image on an OUTBOUND MMS. Inbound media stays on Twilio's CDN and is\n  # referenced by URL in media_urls.\n  has_one_attached :media_file\n\n  validates :direction, inclusion: { in: DIRECTIONS }\n  validates :from_number, :to_number, presence: true\n\n  scope :inbound,      -\u003e { where(direction: \"inbound\") }\n  scope :outbound,     -\u003e { where(direction: \"outbound\") }\n  scope :unread,       -\u003e { inbound.where(read_at: nil) }\n  scope :with_number,  -\u003e(n) { where(counterpart_number: normalize_thread_key(n)) }\n\n  before_validation :set_counterpart_number\n\n  # \"+1 (801) 555-0100\", \"8015550100\", \"18015550100\" all -\u003e \"+18015550100\".\n  def self.normalize_number(raw)\n    digits = raw.to_s.gsub(/\\D/, \"\")\n    return raw.to_s if digits.blank?\n    digits = \"1#{digits}\" if digits.length == 10\n    \"+#{digits}\"\n  end\n\n  # A thread key is either a phone number or a group conversation SID (CH...).\n  # ALWAYS use this on a value that might be either — normalize_number strips the\n  # letters out of a CH sid and silently destroys it.\n  def self.normalize_thread_key(key)\n    key.to_s.start_with?(\"CH\") ? key.to_s : normalize_number(key)\n  end\n\n  def inbound? = direction == \"inbound\"\n  def group?   = conversation_sid.present?\n\n  # Latest message per counterpart, newest conversation first.\n  def self.conversations\n    latest = select(\"DISTINCT ON (counterpart_number) id\").order(:counterpart_number, created_at: :desc)\n    where(id: latest).order(created_at: :desc)\n  end\n\n  def self.unread_counts = unread.group(:counterpart_number).count\n\n  # The user this number belongs to, if any. Phone columns are free text, so match\n  # on the last 10 digits.\n  def matched_user\n    digits = counterpart_number.to_s.gsub(/\\D/, \"\").last(10)\n    return nil if digits.blank?\n    User.where(\"regexp_replace(phone, '\\\\D', '', 'g') LIKE ?\", \"%#{digits}\").first\n  end\n\n  private\n\n  def set_counterpart_number\n    external = inbound? ? from_number : to_number\n    self.counterpart_number = conversation_sid.presence || self.class.normalize_number(external)\n  end\nend\n```\n\n---\n\n## Layer 3 — SmsGateway (all send and receive logic)\n\nEvery message in or out goes through this one module, and **every** message becomes a\nrow — including failures.\n\n```ruby\n# app/services/sms_gateway.rb\nmodule SmsGateway\n  # The number lives in CODE, not .env, on purpose: this file hot-reloads on save,\n  # while a new .env var needs a container recreate (= downtime).\n  INBOX_NUMBER = \"+18015550100\"\n\n  # Public base URL of this app, used to build webhook URLs and MMS media links.\n  APP_HOST = ENV.fetch(\"APP_HOST\", \"https://yourapp.example.com\")\n\n  # A restricted Twilio API key cannot sign-verify X-Twilio-Signature (that needs the\n  # account auth token). So the webhook URL carries a secret path token instead,\n  # derived from secret_key_base — nothing new to store, nothing new to rotate.\n  # The controller also requires the posted AccountSid to match ours.\n  def self.webhook_token\n    OpenSSL::HMAC.hexdigest(\"SHA256\", Rails.application.secret_key_base, \"twilio-sms-webhook\")[0, 32]\n  end\n\n  def self.webhook_url               = \"#{APP_HOST}/api/twilio/sms/#{webhook_token}\"\n  def self.status_callback_url       = \"#{APP_HOST}/api/twilio/sms_status/#{webhook_token}\"\n  def self.conversations_webhook_url = \"#{APP_HOST}/api/twilio/conversations/#{webhook_token}\"\n\n  # Sends an SMS/MMS and logs it. ALWAYS returns the row — on a Twilio failure the row\n  # is saved with status \"failed\" and the error. Nothing raises out of here, so CHECK\n  # the status; do not assume success.\n  def self.send_sms(to:, body:, from: INBOX_NUMBER, media_upload: nil)\n    to = SmsMessage.normalize_number(to)\n    message = SmsMessage.new(direction: \"outbound\", from_number: from, to_number: to, body: body)\n\n    if media_upload\n      message.media_file.attach(media_upload)\n      message.save!   # the blob must be persisted before it has a URL\n      message.num_media = 1\n      message.media_urls = [Rails.application.routes.url_helpers.rails_blob_url(\n        message.media_file, host: APP_HOST)]\n    end\n\n    begin\n      # twilio-ruby takes KEYWORD args. A positional hash raises ArgumentError — splat it.\n      twilio_message = Twilio.get_client.messages.create(\n        **{ from: from, to: to, body: body,\n            status_callback: status_callback_url,\n            media_url: message.media_urls.presence }.compact\n      )\n      message.twilio_sid = twilio_message.sid\n      message.status     = twilio_message.status\n    rescue =\u003e e\n      message.status = \"failed\"\n      message.error_message = \"#{e.class}: #{e.message}\"\n    end\n\n    message.save!\n    message\n  end\n\n  # Called by the inbound webhook. Returns the created row.\n  def self.receive(params)\n    n = params[\"NumMedia\"].to_i\n    message = SmsMessage.create!(\n      direction: \"inbound\",\n      from_number: params[\"From\"],\n      to_number: params[\"To\"],\n      body: params[\"Body\"],\n      twilio_sid: params[\"MessageSid\"],\n      status: \"received\",\n      num_media: n,\n      media_urls: (0...n).map { |i| params[\"MediaUrl#{i}\"] }.compact,\n      metadata: params.slice(\"FromCity\", \"FromState\", \"FromZip\", \"FromCountry\", \"SmsStatus\")\n                      .merge(\"media_content_types\" =\u003e (0...n).map { |i| params[\"MediaContentType#{i}\"] }.compact)\n    )\n    notify_owner(message)\n    message\n  end\n\n  # Alert the owner that someone texted in. SMS truncates around 300 characters and\n  # chops a trailing URL, so the reply link goes FIRST.\n  def self.notify_owner(message)\n    return unless ENV[\"SMS_INBOUND_ALERTS\"] == \"true\"\n    return if OWNER_NUMBERS.include?(message.from_number.to_s.delete(\"^0-9\").last(10))\n\n    thread_url = \"#{APP_HOST}/inbox/#{message.counterpart_number.delete('+')}\"\n    OWNER_NUMBERS.each do |digits|\n      send_sms(to: digits, body: \"New text — reply: #{thread_url} — \\\"#{message.body.to_s.truncate(140)}\\\"\")\n    end\n  rescue =\u003e e\n    Rails.logger.error(\"SmsGateway.notify_owner failed: #{e.class}: #{e.message}\")\n  end\n\n  OWNER_NUMBERS = %w[8015550199].freeze   # last 10 digits, no punctuation\nend\n```\n\n`Twilio.get_client` comes from the\n[Twilio cookbook](/cookbook/twilio-sms-and-phone-verification) — copy that module in\nfirst if you have not already.\n\n---\n\n## Layer 4 — The webhook controller \u0026 routes\n\n```ruby\n# app/controllers/api/twilio_sms_controller.rb\n#\n# Auth: the URL path carries a secret token, and the posted AccountSid must match ours.\n# A bad token or AccountSid returns 404 — indistinguishable from a wrong route, so a\n# prober learns nothing.\nclass Api::TwilioSmsController \u003c ActionController::Base\n  skip_before_action :verify_authenticity_token\n  before_action :verify_webhook!\n\n  # POST /api/twilio/sms/:token — inbound message.\n  # Reply with EMPTY TwiML so Twilio does not auto-respond to the sender.\n  def receive\n    SmsGateway.receive(params.to_unsafe_h)\n    render xml: '\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cResponse\u003e\u003c/Response\u003e'\n  rescue ActiveRecord::RecordNotUnique\n    # Twilio retries on a slow response; the unique twilio_sid index makes it a no-op.\n    render xml: '\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cResponse\u003e\u003c/Response\u003e'\n  end\n\n  # POST /api/twilio/sms_status/:token — delivery receipt for an outbound send.\n  def status\n    SmsMessage.find_by(twilio_sid: params[\"MessageSid\"])\u0026.update(\n      status: params[\"MessageStatus\"],\n      error_message: params[\"ErrorCode\"].presence \u0026\u0026 \"Twilio error #{params['ErrorCode']}\"\n    )\n    head :ok\n  end\n\n  # POST /api/twilio/conversations/:token — group message added (Layer 6).\n  def conversation_event\n    SmsGateway.receive_group_event(params.to_unsafe_h)\n    head :ok\n  rescue ActiveRecord::RecordNotUnique\n    head :ok\n  end\n\n  private\n\n  def verify_webhook!\n    token_ok = ActiveSupport::SecurityUtils.secure_compare(params[:token].to_s, SmsGateway.webhook_token)\n    account_ok = action_name == \"conversation_event\" ||\n                 (params[\"AccountSid\"].present? \u0026\u0026 params[\"AccountSid\"] == ENV[\"TWILIO_ACCOUNT_SID\"])\n    head :not_found unless token_ok \u0026\u0026 account_ok\n  end\nend\n```\n\n```ruby\n# config/routes.rb\npost 'api/twilio/sms/:token',           to: 'api/twilio_sms#receive'\npost 'api/twilio/sms_status/:token',    to: 'api/twilio_sms#status'\npost 'api/twilio/conversations/:token', to: 'api/twilio_sms#conversation_event'\n\nget  '/inbox',          to: 'inbox#index',  as: :inbox_index\npost '/inbox',          to: 'inbox#create'\nget  '/inbox/:number',  to: 'inbox#show',   as: :inbox_thread\npost '/inbox/groups',   to: 'inbox#create_group', as: :inbox_create_group\n```\n\nGet the live webhook URL and paste it into the Twilio Console:\n\n```bash\ndocker compose exec -T llamapress sh -c \\\n  'bin/rails runner \"File.write(%q{/tmp/u}, SmsGateway.webhook_url)\" \u003e/dev/null 2\u003e\u00261; cat /tmp/u'\n```\n\n---\n\n## Layer 5 — The inbox UI\n\nTwo screens. The job of the index is one question: **are there new texts, and from\nwhom?** So layer 1 of the page carries five things and nothing more — who, the newest\nsnippet, when, an amber \"needs you\" badge, and a group marker.\n\n```ruby\n# app/controllers/inbox_controller.rb\nclass InboxController \u003c ApplicationController\n  before_action :authenticate_user!\n\n  def index\n    @conversations = SmsMessage.conversations.limit(200)\n    @unread_counts = SmsMessage.unread_counts\n  end\n\n  # Viewing a thread marks its inbound messages read.\n  def show\n    @number   = SmsMessage.normalize_thread_key(params[:number])\n    @messages = SmsMessage.with_number(@number).order(:created_at)\n    raise ActiveRecord::RecordNotFound if @messages.empty?\n    SmsMessage.with_number(@number).unread.update_all(read_at: Time.current)\n  end\n\n  def create\n    to, body, media = params[:to].to_s, params[:body].to_s.strip, params[:media]\n\n    if to.gsub(/\\D/, \"\").length \u003c 10 || (body.blank? \u0026\u0026 media.blank?)\n      return redirect_back fallback_location: inbox_index_path,\n                           alert: \"Need a 10-digit number and a message or image.\"\n    end\n\n    message = SmsGateway.send_sms(to: to, body: body, media_upload: media)\n    path = inbox_thread_path(number: message.counterpart_number.delete(\"+\"))\n    if message.status == \"failed\"\n      redirect_to path, alert: \"Send failed: #{message.error_message}\"\n    else\n      redirect_to path, notice: \"Message sent.\"\n    end\n  end\nend\n```\n\n```erb\n\u003c%# app/views/inbox/index.html.erb\n    JOB: someone checking texts decides who wrote in and what to reply.\n    3-SEC Q: are there new incoming texts, and from whom?\n    L1: name/number · newest snippet · time · amber unread badge. L2: the thread page. %\u003e\n\u003cdiv class=\"w-full max-w-4xl mx-auto px-4 py-6\"\u003e\n  \u003ch1 class=\"font-bold text-3xl text-slate-900\"\u003eText Inbox\u003c/h1\u003e\n  \u003cp class=\"text-sm text-slate-600 mt-1\"\u003e\n    Messages to and from \u003cspan class=\"font-mono\"\u003e\u003c%= SmsGateway::INBOX_NUMBER %\u003e\u003c/span\u003e.\n  \u003c/p\u003e\n\n  \u003cdiv class=\"mt-6 bg-white rounded-lg shadow border border-slate-200 divide-y divide-slate-100\"\u003e\n    \u003c% if @conversations.empty? %\u003e\n      \u003cdiv class=\"px-4 py-10 text-center text-slate-400\"\u003eNo messages yet.\u003c/div\u003e\n    \u003c% end %\u003e\n\n    \u003c% @conversations.each do |message| %\u003e\n      \u003c% unread = @unread_counts[message.counterpart_number].to_i %\u003e\n      \u003c%= link_to inbox_thread_path(number: message.counterpart_number.delete(\"+\")),\n                  class: \"flex items-center gap-4 px-4 py-3 hover:bg-slate-50\" do %\u003e\n        \u003cdiv class=\"flex-1 min-w-0\"\u003e\n          \u003cdiv class=\"flex items-center gap-2\"\u003e\n            \u003cspan class=\"font-mono text-sm \u003c%= unread.positive? ? 'font-bold text-slate-900' : 'text-slate-700' %\u003e\"\u003e\n              \u003c%= \"👥 \" if message.group? %\u003e\u003c%= message.counterpart_number %\u003e\n            \u003c/span\u003e\n            \u003c% if unread.positive? %\u003e\n              \u003cspan class=\"px-2 py-0.5 text-xs font-semibold bg-amber-100 text-amber-800 rounded-full\"\u003e\n                \u003c%= unread %\u003e new\n              \u003c/span\u003e\n            \u003c% end %\u003e\n          \u003c/div\u003e\n          \u003cdiv class=\"text-sm text-slate-500 truncate mt-0.5\"\u003e\n            \u003c%= message.inbound? ? \"\" : \"You: \" %\u003e\u003c%= message.body.presence\u0026.truncate(90) %\u003e\n          \u003c/div\u003e\n        \u003c/div\u003e\n        \u003cdiv class=\"text-xs text-slate-400 whitespace-nowrap\"\u003e\n          \u003c%= time_ago_in_words(message.created_at) %\u003e ago\n        \u003c/div\u003e\n      \u003c% end %\u003e\n    \u003c% end %\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\nThe thread page is a transcript: inbound messages left and light, outbound right and\ntinted, a reply box pinned at the bottom. Position carries the \"theirs vs ours\"\ndistinction; colour is reserved for the one thing that needs a human — the unread badge.\n\n---\n\n## Layer 6 — Group texts (optional)\n\nA group thread lets the agent ask **two** people a question in one message and read both\nreplies in one place. It is a Twilio **Conversations** conversation: the external numbers\njoin as SMS participants, you join as an identity projected from your inbox number, and a\nconversation-scoped webhook delivers replies.\n\n```ruby\n# app/services/sms_gateway.rb  (continued)\nmodule SmsGateway\n  CONVERSATIONS_IDENTITY = \"app\"\n\n  def self.create_group_conversation(numbers:, name: nil)\n    numbers = numbers.map { |n| SmsMessage.normalize_number(n) }\n    client  = Twilio.get_client\n    convo   = client.conversations.v1.conversations.create(\n      friendly_name: name.presence || \"Group with #{numbers.join(', ')}\")\n\n    numbers.each do |n|\n      client.conversations.v1.conversations(convo.sid)\n            .participants.create(messaging_binding_address: n)\n    end\n    client.conversations.v1.conversations(convo.sid).participants.create(\n      identity: CONVERSATIONS_IDENTITY, messaging_binding_projected_address: INBOX_NUMBER)\n\n    # Conversation-SCOPED webhook: a restricted API key usually lacks the grant to\n    # register a global one.\n    client.conversations.v1.conversations(convo.sid).webhooks.create(\n      target: \"webhook\", configuration_url: conversations_webhook_url,\n      configuration_filters: [\"onMessageAdded\"], configuration_method: \"POST\")\n\n    SmsMessage.create!(direction: \"outbound\", from_number: INBOX_NUMBER, to_number: convo.sid,\n                       conversation_sid: convo.sid, author: CONVERSATIONS_IDENTITY,\n                       body: \"(group created: #{numbers.join(', ')})\", status: \"note\",\n                       metadata: { \"group_name\" =\u003e name.presence, \"participants\" =\u003e numbers })\n  end\n\n  def self.send_group_message(conversation_sid:, body:)\n    message = SmsMessage.new(direction: \"outbound\", from_number: INBOX_NUMBER,\n                             to_number: conversation_sid, conversation_sid: conversation_sid,\n                             author: CONVERSATIONS_IDENTITY, body: body)\n    begin\n      tm = Twilio.get_client.conversations.v1.conversations(conversation_sid)\n                 .messages.create(author: CONVERSATIONS_IDENTITY, body: body)\n      message.twilio_sid = tm.sid\n      message.status     = \"sent\"\n    rescue =\u003e e\n      message.status = \"failed\"\n      message.error_message = \"#{e.class}: #{e.message}\"\n    end\n    message.save!\n    message\n  end\n\n  # Our OWN authored messages echo back through this webhook — drop them.\n  def self.receive_group_event(params)\n    return nil unless params[\"EventType\"] == \"onMessageAdded\"\n    return nil if params[\"Author\"] == CONVERSATIONS_IDENTITY\n\n    media = params[\"Media\"].present? ? JSON.parse(params[\"Media\"]) : []\n    SmsMessage.create!(direction: \"inbound\", from_number: params[\"Author\"],\n                       to_number: INBOX_NUMBER, conversation_sid: params[\"ConversationSid\"],\n                       author: params[\"Author\"], twilio_sid: params[\"MessageSid\"],\n                       status: \"received\", body: params[\"Body\"], num_media: media.length,\n                       metadata: { \"conversation_media\" =\u003e media })\n  end\nend\n```\n\n**Group limits, all Twilio's, all deliberate:** at most 10 participants; **+1 long codes\nonly** (toll-free and short codes cannot group text); text-only sends unless you build\nTwilio's separate media upload flow; and **the group must start from your side** unless\nyou configure address auto-creation.\n\n---\n\n## Layer 7 — The agent loop: Codex CLI + `/goal`\n\nEverything above is a gateway. This is what makes it staffed.\n\nThe design in one sentence: **one Codex session holds a standing goal; each turn does one\nsweep of the inboxes and then sleeps ~10 minutes; when the turn ends and the thread goes\nidle, Codex automatically continues the goal, which starts the next tick.** The goal is\nthe loop's engine. The sleep is its clock.\n\n### 7a. Install and sign in\n\n`node` and `npm` are already on a Leo box; `codex` is not.\n\n```bash\nnpm install -g @openai/codex\ncodex login          # device-flow: it prints a URL, you authorize in a browser\ncodex --version\n```\n\nConfirm the goals feature is on (it is stable and on by default):\n\n```bash\ncodex features list | grep goals      # -\u003e goals  stable  true\n```\n\n### 7b. Give the agent the facts (`AGENTS.md`)\n\nCodex reads `AGENTS.md` from the working directory. Put the things it would otherwise\nguess wrong there — once — so the goal itself stays short.\n\n```markdown\n\u003c!-- AGENTS.md (at the repo root) --\u003e\n# Repository Guidelines\n\n## Running Rails\nRuby is not on the host. Every Rails command runs in the container:\n`docker compose exec -T llamapress bin/rails \u003ccmd\u003e`\n\nThrowaway runner scripts go in `rails/db/scripts/` and run as\n`bin/rails runner db/scripts/\u003cname\u003e.rb`. Do NOT write them to `rails/` — only named\nsubdirectories of `rails/` are bind-mounted, so a file at `rails/foo.rb` does not exist\ninside the container.\n\n## The inboxes\n- Text: `SmsGateway` / `SmsMessage`, inbox UI at `/inbox`.\n- Email: `AgentGmailTools` (draft-first — it cannot send without `confirm: true`).\n\n## Standing rules\n- Never send a text or an email to anyone outside the owner group without an explicit\n  human approval in the owner thread.\n- Never commit. Leave changes in the working tree.\n```\n\n### 7c. The tick script (the procedure lives in code, not in the prompt)\n\nA prose procedure rots silently; a script is tested against reality every run. So the\n**data gathering** is a script and only the **judgement** is the agent's.\n\n```ruby\n# rails/db/scripts/agent_inbox_tick.rb\n# One sweep of both inboxes -\u003e /tmp/agent_inbox_tick.json (inside the container).\n# rails/db is always bind-mounted (migrations need it) and Zeitwerk never autoloads db/,\n# so this is the one location that works on every box.\nrequire \"json\"\n\nlast_sms = ENV[\"LAST_SMS_ID\"].to_i\n\nsms = SmsMessage.inbound.where(\"id \u003e ?\", last_sms).order(:id).map do |m|\n  { id: m.id, from: m.from_number, thread: m.counterpart_number, group: m.group?,\n    body: m.body.to_s[0, 500], media: m.num_media, at: m.created_at.utc.iso8601 }\nend\n\n# Email is OPTIONAL: only swept if the Gmail service is installed and connected.\nemail = []\nif defined?(AgentGmailTools) \u0026\u0026 AgentGmailTools.connected_mailboxes.any?\n  AgentGmailTools.connected_mailboxes.each_key do |box|\n    tools = AgentGmailTools.for(box)\n    # newer_than:1h overlaps a 10-minute tick generously, so a swallowed tick or a slow\n    # Gmail index cannot drop a message. in:anywhere because real mail lands in spam.\n    tools.search_emails(query: \"in:anywhere newer_than:1h -in:sent\", max_results: 25).each do |hit|\n      m = tools.read_email(message_id: hit[:id])\n      email \u003c\u003c { mailbox: box, id: m[:id], from: m[:from], subject: m[:subject],\n                 at: m[:date], snippet: m[:snippet].to_s[0, 300] }\n    end\n  end\nend\n\nFile.write(\"/tmp/agent_inbox_tick.json\", JSON.pretty_generate(\n  generated_at: Time.now.utc.iso8601,\n  last_sms_id_seen: last_sms,\n  max_sms_id: SmsMessage.maximum(:id),\n  new_sms: sms,\n  recent_email: email\n))\n```\n\n```bash\n#!/usr/bin/env bash\n# bin/agent-inbox-tick.sh — gather one tick into tmp/agent_inbox/tick.json on the host.\nset -euo pipefail\ncd \"$(dirname \"$0\")/..\"\nmkdir -p tmp/agent_inbox\n\nSTATE=tmp/agent_inbox/state.json\nLAST_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)\")\n\n# Write the result to a file INSIDE the container, then cat it out. Never parse the\n# runner's stdout directly — this stack prints an auth token to stdout on boot, which\n# would land in your JSON and in the agent's context.\ndocker compose exec -T -e LAST_SMS_ID=\"$LAST_SMS\" llamapress \\\n  sh -c 'bin/rails runner db/scripts/agent_inbox_tick.rb \u003e/dev/null 2\u003e\u00261; cat /tmp/agent_inbox_tick.json' \\\n  \u003e tmp/agent_inbox/tick.json\n\necho \"--- tick $(date -u +%FT%TZ) (since sms id $LAST_SMS) ---\"\ncat tmp/agent_inbox/tick.json\n```\n\n```bash\nchmod +x bin/agent-inbox-tick.sh\n./bin/agent-inbox-tick.sh          # run it once by hand before you hand it to an agent\n```\n\n### 7d. The `/goal` itself\n\nStart Codex in the project directory, then type `/goal` followed by the objective. Codex\nstores it against the thread; a long objective is written to a file for you, so length is\nfine.\n\n```\ncodex\n```\n\n```\n/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.\n\nEACH TICK, in order:\n1. Run ./bin/agent-inbox-tick.sh and read tmp/agent_inbox/tick.json.\n2. 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.\n3. 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.\n4. 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?\".\n5. 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.\n6. 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.\n7. 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.\n8. 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.\n\nNever commit. Never text or email anyone outside the owner group without my explicit approval in the owner thread.\n```\n\nThen confirm the goal took, and manage it:\n\n| Command | What it does |\n|---|---|\n| `/goal \u003cobjective\u003e` | Sets the standing objective for this thread |\n| `/goal` | Shows the current goal, its status, and usage so far |\n| `/goal pause` | Stops the automatic continuations — the loop halts, the goal survives |\n| `/goal resume` | Restarts the loop from where it stopped |\n| `/goal edit` | Change the objective without losing the thread |\n| `/goal clear` | Ends the goal for good |\n\nA goal is `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`, or `complete`.\n**Only `pause` and `resume` are yours** — the agent can only mark a goal `complete` or\n`blocked`, and it is instructed not to claim `blocked` until the same obstacle has\nrecurred across three consecutive turns.\n\n### 7e. Why the loop keeps going\n\nWhen a turn ends and the thread has an active goal, Codex issues its own continuation\nturn — \"Continue working toward the active thread goal\" — with the objective and the\nremaining budget attached. That continuation is what starts the next tick. The `sleep 600`\nat the end of step 8 is what stops those continuations from firing back to back and\nburning your budget in an afternoon.\n\nProve the cadence before you trust it. Let it run twice, then read the timestamps:\n\n```bash\npython3 -c \"import json;print(json.load(open('tmp/agent_inbox/state.json'))['last_tick_at'])\"\n```\n\nTwo ticks about ten minutes apart means the sleep survived. If they are seconds apart, the\nsleep was cut short by the shell tool's timeout — go to 7f, which does not depend on the\nsleep at all.\n\n### 7f. The backstop: a cron tick and a dead-man's switch\n\n**A session-driven loop dies with its session, silently.** Close the terminal, lose the\nSSH connection, reboot the box — the texts simply stop, and nothing tells you. Two lines\nof defence, and you want both:\n\n```cron\n# Time-driven tick. Resumes the SAME thread, so the goal and its history carry over.\n*/10 * * * * /home/ubuntu/.local/bin/codex exec resume --last \\\n  \"Run one tick of the standing inbox goal now.\" \u003e\u003e log/agent-inbox-cron.log 2\u003e\u00261\n\n# Dead-man's switch: if last_tick_at goes stale, text the owner once.\n*/5 * * * * /home/ubuntu/Leonardo/bin/agent-loop-watchdog.sh\n```\n\n```bash\n#!/usr/bin/env bash\n# bin/agent-loop-watchdog.sh — a dead loop cannot text you about its own death.\nset -euo pipefail\ncd \"$(dirname \"$0\")/..\"\nSTATE=tmp/agent_inbox/state.json\nSENTINEL=tmp/agent_inbox/watchdog.alerted\n[ -f tmp/agent_inbox/watchdog.disarm ] \u0026\u0026 exit 0     # deliberate stop\n\nAGE=$(python3 - \"$STATE\" \u003c\u003c'PY'\nimport json, sys, os, datetime\np = sys.argv[1]\nif not os.path.exists(p): print(999999); raise SystemExit\nt = json.load(open(p)).get(\"last_tick_at\")\nlast = datetime.datetime.fromisoformat(t.replace(\"Z\", \"+00:00\"))\nprint(int((datetime.datetime.now(datetime.timezone.utc) - last).total_seconds() / 60))\nPY\n)\n\nif [ \"$AGE\" -gt 35 ] \u0026\u0026 [ ! -f \"$SENTINEL\" ]; then\n  docker compose exec -T llamapress sh -c \\\n    \"bin/rails runner \\\"SmsGateway.send_sms(to: SmsGateway::OWNER_NUMBERS.first, body: 'Inbox loop looks DEAD — last tick ${AGE} min ago.')\\\" \u003e/dev/null 2\u003e\u00261\"\n  touch \"$SENTINEL\"\nelif [ \"$AGE\" -le 35 ] \u0026\u0026 [ -f \"$SENTINEL\" ]; then\n  rm -f \"$SENTINEL\"                                   # recovered\nfi\n```\n\n**35 minutes, not 15, on purpose.** Interactive use swallows ticks: a continuation only\nfires while the session is idle, so any command you type in that session skips that tick.\nA 20-minute gap is normal, not a fault.\n\n**Stopping the loop deliberately?** `touch tmp/agent_inbox/watchdog.disarm` first, or the\nwatchdog texts you a false death alert. Delete it when you restart.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n**The gateway**\n\n1. **`twilio-ruby`'s `messages.create` takes keyword arguments only.** A positional hash —\n   `create({...}.compact)` — fails with `ArgumentError: wrong number of arguments (given\n   1, expected 0)`. Splat it: `create(**hash)`.\n2. **An outbound MMS media URL 403s on `HEAD` and 200s on `GET`** with Active Storage's\n   disk service. Twilio uses GET, so it works. Do not \"verify\" with `curl -I` and conclude\n   the link is broken.\n3. **Always use `normalize_thread_key`, never `normalize_number`, on a value that might be\n   a group SID.** `normalize_number` strips the letters out of `CH...` and silently\n   destroys the key.\n4. **A restricted Twilio API key cannot manage phone numbers** (listing, buying, or editing\n   a number's webhook returns 401, error 70051), and often cannot register a global\n   Conversations webhook. Set the number's webhook by hand in the Console; scope the\n   Conversations webhook per conversation as Layer 6 does.\n5. **Signature validation needs the account auth token, which a restricted key is not.**\n   That is why the secret is in the URL path. If you do hold the auth token, prefer\n   Twilio's real `X-Twilio-Signature` validation and keep the AccountSid check as a belt.\n6. **`.env` changes need a container recreate**, not a restart:\n   `docker compose up -d --force-recreate llamapress`. This is exactly why\n   `INBOX_NUMBER` is a constant in a hot-reloading file instead of an env var.\n7. **`config/routes.rb` is often a single-FILE bind mount.** An atomic-write editor\n   replaces the host file's inode and detaches it from the mount: the host file updates,\n   the running app keeps reading the old one, and your route silently never registers.\n   After editing, rewrite it in place inside the container and verify:\n   `docker compose exec -T llamapress sh -c 'cat \u003e /rails/config/routes.rb' \u003c rails/config/routes.rb`\n   then `bin/rails routes | grep twilio`.\n8. **Test the webhook without a phone.** Post to your own endpoint — it exercises the real\n   stack, including the alert path, so use an obviously fake body:\n\n   ```bash\n   TOK=$(docker compose exec -T llamapress sh -c \\\n     'bin/rails runner \"File.write(%q{/tmp/t}, SmsGateway.webhook_token)\" \u003e/dev/null 2\u003e\u00261; cat /tmp/t')\n   curl -s -X POST \"http://127.0.0.1:3000/api/twilio/sms/$TOK\" \\\n     --data-urlencode \"AccountSid=$TWILIO_ACCOUNT_SID\" \\\n     --data-urlencode \"MessageSid=SMtest$(date +%s)\" \\\n     --data-urlencode \"From=+15005550006\" --data-urlencode \"To=+18015550100\" \\\n     --data-urlencode \"NumMedia=0\" --data-urlencode \"Body=webhook test\"\n   ```\n\n**The loop**\n\n9. **Write throwaway runner scripts to `rails/db/scripts/`, never to `rails/`.** Only named\n   subdirectories of `rails/` are bind-mounted, so `rails/tick.rb` on the host does not\n   exist at `/rails/tick.rb` in the container and `rails runner` answers \"could not be\n   found\". `rails/db` is always mounted, because migrations need it.\n10. **Never parse `rails runner` stdout.** This stack prints an auth token to stdout at\n    boot. Write results to a file inside the container and `cat` that file — otherwise the\n    token lands in your JSON, your logs, and the agent's context window.\n11. **A token budget silently ends the loop.** If you give the goal a budget, exhausting it\n    flips the status to `budget_limited`, and the agent is instructed to wrap up rather\n    than start new work. For a standing loop, either omit the budget or check `/goal`\n    occasionally. `usage_limited` means your account hit its rate limit — same effect,\n    different cause.\n12. **The goal survives a resume, and only a resume.** `codex resume --last` restores the\n    thread's goal and the loop picks up. Starting a *fresh* `codex` session does not — it\n    has no goal, so it sits there while you assume it is working. This is also why the\n    cron backstop uses `codex exec resume --last` rather than plain `codex exec`.\n13. **The agent may not mark the goal complete for you.** A standing loop has no end\n    state, so say so in the objective (\"this objective is never complete, do not call\n    `update_goal`\"). Without that line an agent that finishes a quiet tick may reasonably\n    decide the work is done and stop the loop.\n14. **Dedup email on sender + subject + timestamp, not on message id.** The same email has\n    a *different* id in every mailbox it reached, so a customer who mails one address and\n    copies another appears twice with two unrelated ids. Record every per-mailbox id you\n    saw, but decide with the tuple.\n15. **A quiet tick still writes the state file.** `last_tick_at` is the heartbeat. Skipping\n    it when nothing happened makes the watchdog cry wolf every night.\n16. **\"Nobody replied\" is only provable for connected mailboxes.** If mail went to an\n    address you are not connected to, someone may have answered it invisibly. Have the\n    agent say \"I see no reply in the mailboxes I can read\", never \"nobody answered\".\n\n---\n\n## Files this pattern touches\n\n```\ndb/migrate/20260820000001_create_sms_messages.rb\napp/models/sms_message.rb\napp/services/sms_gateway.rb\napp/services/twilio.rb                     # from the Twilio cookbook\napp/controllers/api/twilio_sms_controller.rb\napp/controllers/inbox_controller.rb\napp/views/inbox/index.html.erb\napp/views/inbox/show.html.erb\nconfig/routes.rb\n.env                                       # TWILIO_SID, TWILIO_AUTH, TWILIO_ACCOUNT_SID\n\nAGENTS.md                                  # what Codex reads on every turn\nbin/agent-inbox-tick.sh                    # one sweep -\u003e tmp/agent_inbox/tick.json\nbin/agent-loop-watchdog.sh                 # dead-man's switch\nrails/db/scripts/agent_inbox_tick.rb       # the runner half of the sweep\ntmp/agent_inbox/state.json                 # cursors, seen ids, pending asks, heartbeat\n```\n\n---\n\n## How to adapt to your schema\n\n1. **Swap the owner channel.** `OWNER_NUMBERS` and `SmsGateway::INBOX_NUMBER` are the only\n   two constants carrying your phone numbers. If you would rather be asked by email than\n   by text, keep everything else and have step 4 of the goal draft an email through\n   `AgentGmailTools` instead.\n2. **No `User` model?** Delete `matched_user`. Nothing else in the gateway needs one.\n3. **Change the cadence** in exactly two places: the `sleep` in step 8 of the goal, and the\n   cron expression. Keep the watchdog threshold at roughly three times the tick, or\n   swallowed ticks will page you.\n4. **Different approval rule?** The gate is one sentence of the objective (step 5). Loosen\n   it to \"reply directly to questions answerable from the wiki, ask me about anything else\"\n   once you trust it — but change the sentence, not the code, so the rule stays in one\n   place and stays readable.\n5. **Safe to drop:** Layer 6 (groups) if one owner is enough, MMS media if you only send\n   text, and `notify_owner` once the agent loop is the thing telling you.\n   **Do not drop:** the unique `twilio_sid` index (Twilio retries), the empty-TwiML\n   response (Twilio auto-replies to the sender without it), or the state file (without it\n   every tick re-reports the same messages).\n6. **Using Claude Code instead of Codex?** Everything except Layer 7d transfers unchanged —\n   the tick script, the state file, and the watchdog are agent-agnostic. Swap `/goal` for\n   that tool's own recurring-task mechanism and keep the same objective text.\n"}