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
- Buy a +1 long code in the Twilio Console and note it. Set
TWILIO_SID,TWILIO_AUTH,TWILIO_ACCOUNT_SIDin.env, thendocker compose up -d --force-recreate llamapress(arestartdoes not reload.env). - Create the
sms_messagestable and run the migration immediately. - Copy
SmsMessage,SmsGateway, andApi::TwilioSmsControllerin; add the three webhook routes and the four inbox routes. - Paste
SmsGateway.webhook_urlinto the number's "A message comes in" webhook field in the Twilio Console (HTTP POST). - Text the number from your phone. A row appears;
/inboxshows the thread. - Install Codex CLI on the box, open one session, and type the
/goalfrom 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
twilio-ruby'smessages.createtakes keyword arguments only. A positional hash —create({...}.compact)— fails withArgumentError: wrong number of arguments (given 1, expected 0). Splat it:create(**hash).An outbound MMS media URL 403s on
HEADand 200s onGETwith Active Storage's disk service. Twilio uses GET, so it works. Do not "verify" withcurl -Iand conclude the link is broken.Always use
normalize_thread_key, nevernormalize_number, on a value that might be a group SID.normalize_numberstrips the letters out ofCH...and silently destroys the key.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.
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-Signaturevalidation and keep the AccountSid check as a belt..envchanges need a container recreate, not a restart:docker compose up -d --force-recreate llamapress. This is exactly whyINBOX_NUMBERis a constant in a hot-reloading file instead of an env var.config/routes.rbis 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.rbthenbin/rails routes | grep twilio.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
- Write throwaway runner scripts to
rails/db/scripts/, never torails/. Only named subdirectories ofrails/are bind-mounted, sorails/tick.rbon the host does not exist at/rails/tick.rbin the container andrails runneranswers "could not be found".rails/dbis always mounted, because migrations need it. - Never parse
rails runnerstdout. This stack prints an auth token to stdout at boot. Write results to a file inside the container andcatthat file — otherwise the token lands in your JSON, your logs, and the agent's context window. - 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/goaloccasionally.usage_limitedmeans your account hit its rate limit — same effect, different cause. - The goal survives a resume, and only a resume.
codex resume --lastrestores the thread's goal and the loop picks up. Starting a freshcodexsession does not — it has no goal, so it sits there while you assume it is working. This is also why the cron backstop usescodex exec resume --lastrather than plaincodex exec. - 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. - 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.
- A quiet tick still writes the state file.
last_tick_atis the heartbeat. Skipping it when nothing happened makes the watchdog cry wolf every night. - "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
- Swap the owner channel.
OWNER_NUMBERSandSmsGateway::INBOX_NUMBERare 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 throughAgentGmailToolsinstead. - No
Usermodel? Deletematched_user. Nothing else in the gateway needs one. - Change the cadence in exactly two places: the
sleepin 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. - 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.
- Safe to drop: Layer 6 (groups) if one owner is enough, MMS media if you only send
text, and
notify_owneronce the agent loop is the thing telling you. Do not drop: the uniquetwilio_sidindex (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). - 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
/goalfor that tool's own recurring-task mechanism and keep the same objective text.