10,378 bytes · last changed 2026-10-27 in Simplify the text on pyrtan.com
| 1 | <!doctype html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <title>Pyrtan</title> |
| 7 | <meta name="description" content="Open-source automation built on decision trees you can read. It answers like an assistant, with no AI model inside."> |
| 8 | <meta name="theme-color" content="#c2410c"> |
| 9 | <script src="theme.js"></script> |
| 10 | <style> |
| 11 | :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 } |
| 12 | @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } } |
| 13 | :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } |
| 14 | :root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) } |
| 15 | * { box-sizing:border-box } |
| 16 | body { margin:0; background:var(--bg); color:var(--fg); font:17px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif } |
| 17 | main { max-width:820px; margin:0 auto; padding:48px 16px 64px } |
| 18 | h1 { font-size:34px; margin:0 0 8px; letter-spacing:-.01em } |
| 19 | p.lede { font-size:20px; color:var(--mute); margin:0 0 28px } |
| 20 | h2 { font-size:19px; margin:36px 0 10px } |
| 21 | .actions { display:flex; flex-wrap:wrap; gap:10px; margin:0 0 8px } |
| 22 | .actions a { display:inline-block; padding:10px 16px; border-radius:8px; text-decoration:none; font-weight:600; border:1px solid var(--line); background:var(--card); color:var(--fg) } |
| 23 | .actions a:hover { border-color:var(--tan) } |
| 24 | .actions a.primary { background:var(--accent); color:var(--accent-fg); border-color:var(--accent) } |
| 25 | pre { padding:12px 14px; background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:auto; font:13.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap } |
| 26 | code { font:14px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--soft); padding:1px 5px; border-radius:4px } |
| 27 | ul { padding-left:20px } li { margin:4px 0 } |
| 28 | .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px } |
| 29 | .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px } |
| 30 | .card h3 { margin:0 0 6px; font-size:16px } .card p { margin:0; color:var(--mute); font-size:15px } |
| 31 | a { color:var(--accent) } |
| 32 | .pyr { color:var(--pyr) } .tan { color:var(--tan) } |
| 33 | :focus-visible { outline:2px solid var(--tan); outline-offset:2px } |
| 34 | ::selection { background:var(--tan); color:var(--tan-fg) } |
| 35 | footer { color:var(--mute); font-size:14px; margin-top:48px; border-top:1px solid var(--line); padding-top:16px } |
| 36 | table { width:100%; border-collapse:collapse; font-size:15px } td,th { text-align:left; padding:6px 8px; border-top:1px solid var(--line); vertical-align:top } th { color:var(--mute); font-weight:600; border-top:0 } |
| 37 | .link { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; vertical-align:middle; background:var(--mute) } |
| 38 | .link.up { background:#1f7a4d } .link.stale { background:#8a6400 } .link.down { background:#a3382b } |
| 39 | .mute { color:var(--mute) } |
| 40 | </style> |
| 41 | </head> |
| 42 | <body> |
| 43 | <main> |
| 44 | <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span></h1> |
| 45 | <p class="lede">Open-source automation built on decision trees you can read. Ask it something and it answers like an assistant, but there is no AI model inside.</p> |
| 46 | |
| 47 | <div class="actions"> |
| 48 | <a class="primary" href="console/">Open the console</a> |
| 49 | <a href="catalog/">Browse the catalog</a> |
| 50 | <a href="#directory">See the hubs</a> |
| 51 | <a href="source/">Read the source</a> |
| 52 | </div> |
| 53 | |
| 54 | <pre>$ pyrtan run "what's the weather in Oslo" --trees examples |
| 55 | trace: weather |
| 56 | geocode (observe): GET https://geocoding-api.open-meteo.com/v1/search?name=Oslo… -> 200 -> found |
| 57 | found (decide) [exists True]: state.hit = {…} -> forecast |
| 58 | forecast (observe): GET https://api.open-meteo.com/v1/forecast?latitude=59.91… -> 200 -> have-temp |
| 59 | sky (decide) [between [51, 67]]: state.code = 61 -> rain |
| 60 | reply (done): Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.</pre> |
| 61 | |
| 62 | <h2>Why trees instead of a model</h2> |
| 63 | <p>An AI model can handle questions nobody planned for. Pyrtan can't, and it tells you so. In return, every answer is repeatable and free, works offline, only reaches the sites you allow, and shows exactly how it was reached.</p> |
| 64 | |
| 65 | <div class="grid"> |
| 66 | <div class="card"><h3>Trees</h3><p>A tree is one TOML file: the questions it answers, the sites it may reach, and how it decides. No code to write.</p></div> |
| 67 | <div class="card"><h3>Swarm</h3><p>Services run trees. A hub connects them and shows which ones are up. Run one process or many; nothing else changes.</p></div> |
| 68 | <div class="card"><h3>Gaps</h3><p>When a tree meets a case it doesn't cover, Pyrtan writes it down instead of guessing. That list tells you what to add next.</p></div> |
| 69 | <div class="card"><h3>Catalog</h3><p>Trees other people wrote. <code>pyrtan pull NAME</code> downloads one and checks it before saving it.</p></div> |
| 70 | </div> |
| 71 | |
| 72 | <h2>Install</h2> |
| 73 | <pre>uv tool install git+https://github.com/b-i-t-8/pyrtan # or: pip install git+https://github.com/b-i-t-8/pyrtan |
| 74 | pyrtan search weather |
| 75 | pyrtan pull weather --dir trees |
| 76 | pyrtan serve trees --port 8701 # then open http://127.0.0.1:8701/</pre> |
| 77 | <p>Needs Python 3.12 or later and nothing else. Runs on a laptop, a NAS or a container. See the <a href="https://github.com/b-i-t-8/pyrtan/blob/main/deploy/README.md">deployment notes</a>.</p> |
| 78 | |
| 79 | <h2>The console</h2> |
| 80 | <p>Every hub has a web console at <code>/</code>. You can also use it here at <a href="console/">pyrtan.com/console</a>: start your hub with <code>--token</code> and <code>--cors https://pyrtan.com</code>, then connect to it from anywhere. Your browser talks to your hub directly; nothing passes through this site.</p> |
| 81 | |
| 82 | <h2 id="directory">The directory</h2> |
| 83 | <p>Hubs find each other without a central server. Start a hub with <code>--listed</code> and it tells the hubs it is connected to, which pass the word on. Below are the hubs in the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> uses. You can also start a private network of your own; its hubs don't show up here.</p> |
| 84 | <p>This list comes live from the hubs on the <a href="rendezvous/hubs.json">hub list</a>. If none of them answer, you see the <a href="rendezvous/directory.json">hourly snapshot</a> instead. To use a hub, connect to it from your own.</p> |
| 85 | <div class="card"><table id="hubs"><thead><tr><th>Status</th><th>Hub</th><th>Trees</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the hubs…</td></tr></tbody></table></div> |
| 86 | <pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS |
| 87 | pyrtan join --at https://your-hub --token … # join the default network |
| 88 | pyrtan network new home --at https://your-hub --token … # or start your own; prints an invite |
| 89 | pyrtan directory # list the hubs you know of</pre> |
| 90 | <p>To add your hub to the hub list, open a pull request adding its URL to <code>site/rendezvous/hubs.json</code>. It doesn't need to be online all the time.</p> |
| 91 | |
| 92 | <h2>Hosting</h2> |
| 93 | <p>Self-hosting comes first. A hosted version is planned: the same open-source hub, run for you, with secrets, scheduled runs and alerts. It will use the same API, so you can move between the two at any time.</p> |
| 94 | |
| 95 | <footer>Pyrtan v__VERSION__. Trees you write are your own.</footer> |
| 96 | </main> |
| 97 | <script> |
| 98 | const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); |
| 99 | (async () => { |
| 100 | const note = document.getElementById("hubs-note"); |
| 101 | let listed = []; |
| 102 | try { listed = (await (await fetch("rendezvous/hubs.json")).json()).hubs || []; } catch (e) {} |
| 103 | const seen = new Map(); |
| 104 | let answered = 0; |
| 105 | for (const hub of listed) { |
| 106 | try { |
| 107 | const r = await fetch(hub.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}}); |
| 108 | if (!r.ok) continue; |
| 109 | const d = await r.json(); |
| 110 | answered++; |
| 111 | for (const h of d.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h); |
| 112 | } catch (e) {} |
| 113 | } |
| 114 | let source = answered ? `live from ${answered} listed hub${answered > 1 ? "s" : ""}` : ""; |
| 115 | if (!answered) { |
| 116 | // Nobody on the list answered (or the list is empty): the hourly snapshot. |
| 117 | const why = listed.length ? "no listed hub answered" : "no hubs listed yet"; |
| 118 | try { |
| 119 | const snap = await (await fetch("rendezvous/directory.json")).json(); |
| 120 | for (const h of snap.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h); |
| 121 | source = `${why}; showing the snapshot from ${snap.as_of || "?"}`; |
| 122 | } catch (e) { note.textContent = `${why[0].toUpperCase() + why.slice(1)}, and there is no snapshot yet.`; return; } |
| 123 | } |
| 124 | const now = Date.now() / 1000; |
| 125 | for (const h of seen.values()) { const age = now - (h.seen || 0); h.link = age < 600 ? "up" : age < 86400 ? "stale" : "down"; } |
| 126 | const body = document.querySelector("#hubs tbody"); body.innerHTML = ""; |
| 127 | const hubs = [...seen.values()].sort((a, b) => ({up:0, stale:1, down:2}[a.link] - {up:0, stale:1, down:2}[b.link]) || a.id.localeCompare(b.id)); |
| 128 | const cap = document.createElement("tr"); cap.innerHTML = `<td colspan="4" class="mute">${esc(source)}</td>`; body.appendChild(cap); |
| 129 | for (const h of hubs) { |
| 130 | const tr = document.createElement("tr"); |
| 131 | tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`; |
| 132 | body.appendChild(tr); |
| 133 | } |
| 134 | if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">${esc(source)}: no hubs yet. Add yours to the hub list.</td></tr>`; |
| 135 | })(); |
| 136 | </script> |
| 137 | </body> |
| 138 | </html> |