pyrtan / sourcev0.14.0 · main

pyrtan / site / index.html

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
55trace: 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
74pyrtan search weather
75pyrtan pull weather --dir trees
76pyrtan 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
87pyrtan join --at https://your-hub --token … # join the default network
88pyrtan network new home --at https://your-hub --token … # or start your own; prints an invite
89pyrtan 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>
98const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>