The source of poet.travel: each reader gets an AI poet that travels virtually through real places and writes an illustrated journal page every day.
It is a Phoenix 1.8 LiveView app on SQLite. Each poet is an OpenClaw agent running in its own sprites.dev sandbox. This app provisions the agent, drives it, and renders what it writes.
Architecture
The app is the driver, the sprite is the writer
reader (browser, home-screen app, iOS app)
│ LiveView over WebSocket
▼
┌───────────────────────────────┐ wake, send a turn ┌───────────────────────────────┐
│ Phoenix app (one Fly machine) │ ─────────────────────▶ │ sprite (one per poet) │
│ │ OpenClaw gateway WS │ │
│ schedulers and watchdogs │ │ OpenClaw agent │
│ contexts (Journal, Guide, …) │ │ workspace: AGENTS.md, skills/ │
│ SQLite on a volume │ ◀───────────────────── │ tpoet-plugin (the tool set) │
│ /api/agent/* (bearer token) │ tool calls over HTTP │ │
└───────────────────────────────┘ └───────────────────────────────┘
│ │
│ OpenRouter: drawings, place labels │ OpenRouter: the poet's model,
│ Tigris (S3): media │ web search
│ Telegram, Web Push, APNs, email: notifications │
- The poet writes; the app never does. Every journal page is written on the poet's sprite. The app's own model calls only label what a poet already wrote (places, topics) and draw the illustrations, so no shared secret ever reaches a sprite.
- The app owns time and counting. A paused sprite has no clock, so every
cadence lives in the app: when a poet travels (
DailyJourneyScheduler), when a reader's feedback is passed on (Markers.Watchdog), when the poet asks the reader something (Asks.Cadence). Facts such as the journey day, or how many drawings today gets, are computed by the app and handed to the agent in/api/agent/context. - Provisioning.
Provisionercreates the sprite, installs OpenClaw, seeds the workspace frompriv/data/(the agent's rules andskills/*/SKILL.md), installs thetpoet-pluginwhose tools call back into/api/agent/*, and pairs a device key.Provisioner.upgrade_fleet/1rolls a new workspace out to every poet. - Driving.
AgentSessionwakes a sprite, sends a turn, keeps the sprite awake while it works (SpriteHold), and stores the streamed reply.GatewaySocketcloses itself when idle, because an open socket keeps a sprite awake and billing. - Rendering is defensive. Model output is always sanitized before it is
shown (
NotebookComponents.raw_markdown/3, the chat'srender_markdown/1). The renderer also lays out media the model forgot to place, without touching what is stored.
Where things live
| Path | What |
|---|---|
lib/traveling_poet/ |
Contexts: Journal (entries, sections, media), Guide (places and pins), Markers (reader feedback on a page), Preferences and Affinity (what a reader likes), Credits, Trips, Discover, and the sprite side (Provisioner, AgentSession, GatewaySocket, SpriteHold) |
lib/traveling_poet_web/ |
LiveViews (live/), shared components (components/), controllers including the agent API (controllers/api/), plugs |
priv/data/ |
What a poet is given: AGENTS.md, skills/*/SKILL.md, presets, the place topic tree. Read at compile time |
priv/gettext/ |
UI copy. English and Hebrew are both required |
assets/ |
Tailwind v4 CSS, JS hooks (*_hook.js), vendored Leaflet |
test/ |
ExUnit tests; fixtures in test/support/fixtures.ex |
The app runs on exactly one machine: SQLite on a volume, in-process schedulers, one Telegram poller. It is not built to scale horizontally.
The iOS app is a thin Capacitor shell around the same site, in its own repo. Everything it needs from the server is here, and inert in a browser.
CLAUDE.md is the detailed map. It covers each context, the background
processes and their switches, the PubSub topics, and the iOS integration. It
is written for coding agents and reads just as well for people. AGENTS.md
holds the general Phoenix, LiveView, Ecto and Tailwind conventions the code
follows.
Open data: what the poets found
Every place, event and dish a public poet has written about becomes an item in a shared dataset. You can read it in a browser or pull it as data. Nothing from a private poet appears in it, and an item exists only once a public poet has published a page about it.
In the browser: poet.travel/discover shows
it two ways: a world map of the poets and their finds, and the village, a
zoomable map of every place grouped by subject. Links can open it at a
subject, for example
/discover?view=village&topic=food-and-drink.
As data: two read-only endpoints, no key and no sign-in, cached for five minutes.
| Endpoint | Returns |
|---|---|
GET /items.geojson |
Every mapped public item as a GeoJSON FeatureCollection |
GET /items/:slug/jsonld |
One item as schema.org JSON-LD |
GET /items/:slug |
The same item as a web page, with the JSON-LD embedded |
/items.geojson takes three optional filters, which combine:
kind:place,event,dish, and the other item kinds inSpaces.Item.kinds/0.topic: a path on the subject tree inpriv/data/place_topics.json. It matches that subject and everything under it, sotopic=food-and-drinkincludesfood-and-drink/markets-and-food-halls/food-markets-and-halls.country: an ISO 3166 two-letter code, such ascountry=it.
curl 'https://poet.travel/items.geojson?topic=food-and-drink&country=it'
Each feature's id is the item's slug. Its properties carry name,
kind, subkind, city, country, topic and second_topic, plus
starts, ends and era for things in time. found_by is how many public
poets wrote about it. Cities, regions and countries are left out of the feed.
The JSON-LD for an item adds its description, address, coordinates, the city
it sits in, and links to the same place elsewhere (sameAs). What the poets
wrote rides under the tp: prefix (tp:foundBy, tp:notes): who found it,
on which page, and their own words. Read those as a poet's opinion, not as
facts about the place. When two items turn out to be the same place, the
merged slug answers with a permanent redirect (301) to the one that absorbed
it, so follow redirects.
The code behind all of this is Spaces.Public (the visibility rule and the
feed) and Spaces.JsonLd.
The data is licensed under Creative Commons Attribution 4.0 (CC BY 4.0): use it for anything, commercial uses included, as long as you credit "Traveling Poet (poet.travel)" and link back, ideally to the item's page.
Running it locally
You need Elixir 1.18 and Erlang/OTP 27 (the versions in the Dockerfile).
cp .env.example .env # no key is required to boot; see below
mix setup # deps, database, assets
mix phx.server # http://localhost:4000
What works depends on the keys in .env:
-
No keys: the public site and every signed-in page. To sign in without Google, create a user in
iex -S mix phx.server:{:ok, user} = TravelingPoet.Accounts.find_or_create_from_oauth(:google, %{ "sub" => "local-1", "email" => "you@example.com", "name" => "You" })then open
/dev/login/<user.id>. That route exists only in dev. -
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET: real sign-in. -
SPRITES_TOKENandOPENROUTER_API_KEY: a real poet. Onboarding then provisions a sprite that writes pages.PHOENIX_URLmust be reachable from the sprite (a tunnel such as ngrok or Tailscale), because the poet's tools call back into this app.mix tpoet.smoke_poetchecks the whole round trip. -
Tigris (
AWS_*): stored drawings. Without it, drawings do not load. -
Stripe: leave it blank to get a fake checkout page.
Background jobs stay off until their interval is set (for example
JOURNEY_CHECK_INTERVAL_MINUTES). Leave TELEGRAM_BOT_TOKEN empty locally
unless the bot is your own: two pollers on one bot token take each other's
messages.
config/runtime.exs loads .env in every environment, including tests, and
switches every paid or networked service off in :test.
Contributing
Contributions are welcome. For anything larger than a small fix, please open an issue first so we can agree on the shape before you build it.
The workflow
- Fork the repo and branch off
main. Every change, maintainers' included, lands through a pull request. - Run
mix precommitbefore you push. It compiles with warnings as errors, checks for unused deps, formats, checks the translations are up to date, and runs the whole suite. - Open the PR against
main. Say what was wrong, what you changed, and how you checked it. A screenshot helps for anything visible.
Deploys and fleet rollouts are done by the maintainers after merge.
Commit messages
Start with the problem as someone saw it, then the change. The subject is a sentence about the problem:
A reader could not tell the poet a drawing was wrong without the marker tray
<what was observed, why it happened, and what this change does about it>
Tests
- Every behaviour change comes with a test that would have failed before it.
- Tests never reach the network. Outbound HTTP uses
Req, and in tests it goes throughReq.Teststubs; an unstubbed call raises. Calls into a sprite go throughTravelingPoet.SpritesClientRecorder. - A new external integration needs its key pinned or switched off for
:testinconfig/runtime.exs, or the suite will start spending money. - Timers are off in tests. Drive one pass directly instead (for example
DailyJourneyScheduler.run_now/1). - Use the fixtures in
test/support/fixtures.ex. Most tests touch SQLite and runasync: false; only pure functions runasync: true.
UI copy
- Every string a reader sees goes through
gettextordgettext. After adding or changing one, runmix gettext.extract --mergeand fill in the Hebrew inpriv/gettext/he/. A test fails on a missing translation. Hebrew copy avoids gendering the poet. - No em dashes and no emoji in interface text. The wordmark is "Traveling Poet", with Poet in italics.
Changes that need extra care
priv/data/(the poet's rules and skills, and the plugin source inProvisioner) reaches poets only through a fleet rollout after deploy. Say in the PR description that it changes skill text. Runmix compile --forcelocally to pick up an edit there.- The poet's safety rules in
priv/data/AGENTS.md, such as citing real sources for every drawing and link, are part of the product. Please open an issue before loosening any of them. - Model output is rendered only through the sanitizers named above. Never
render it with
raw/1. - New tables hanging off
usersmust cascade on delete (Accounts.Purgedepends on it), and every new schema must be listed inChangeStream.Registry. A test fails otherwise. - Shared views: the owner and public pages share their state and markup
(
NotebookComponents,GuideComponents). Change the shared module rather than forking the markup.
Security
Please report vulnerabilities privately to support@poet.travel rather than in a public issue.
License
The code is under the GNU Affero General Public License v3.0 (AGPL-3.0); see
LICENSE.
Contributions are accepted under the same license. If you run a modified
version as a network service, the license requires you to offer its source to
that service's users. The libraries vendored under assets/vendor keep their
own licenses.
The open data served by /items.geojson and /items/:slug/jsonld is under
CC BY 4.0, as described in Open data.