tinyapps 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tinyapps/SKILL.md ADDED
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: tinyapps
3
+ description: Create, update and delete single-file HTML apps (with a SQLite database) and static artifacts served by tinyapps.
4
+ ---
5
+ # tinyapps
6
+
7
+ Base URL: `<TINYAPPS_BASE_URL>`. If this is a placeholder, ask the user for the URL they open tinyapps at and replace it in this file.
8
+
9
+ - App (saves data): `~/tinyapps/web/apps/<name>.html` → `<base>/apps/<name>`
10
+ - Artifact (static): `~/tinyapps/web/artifacts/<name>.html` → `<base>/artifacts/<name>`
11
+ - Database: `~/tinyapps/data/<name>.sqlite`, apps only, never served. Write it directly with Python `sqlite3`.
12
+ - Names match `^[a-z0-9][a-z0-9-]{0,62}$`. Writing a file publishes it.
13
+
14
+ ## Sandbox
15
+ Pages are one self-contained HTML file (inline or CDN CSS/JS). No storage, cookies, dialogs or popups.
16
+ Apps can't make network requests; artifacts can't reach `/api`.
17
+
18
+ ## Data
19
+ `await app.sql(sql, params)` runs one statement and returns rows. Use `?` params.
20
+ The app and you may write at the same time; keep transactions short.
21
+
22
+ ## Delete (only when asked; permanent)
23
+ `rm ~/tinyapps/web/apps/<name>.html ~/tinyapps/data/<name>.sqlite*` or `rm ~/tinyapps/web/artifacts/<name>.html`
24
+
25
+ ## Check
26
+ `curl -so /dev/null -w '%{http_code}' http://127.0.0.1:8080/<apps|artifacts>/<name>` → 200. Service: `systemctl --user status tinyapps`.
tinyapps/__init__.py ADDED
File without changes
tinyapps/server.py ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """tinyapps: serve AI-written HTML artifacts and apps; each app gets its own SQLite database.
3
+
4
+ Usage:
5
+ tinyapps serve on TINYAPPS_PORT (default 8080)
6
+ tinyapps delete apps <name> delete an app page and its database
7
+ tinyapps delete artifacts <name>
8
+ tinyapps skill print the agent skill (SKILL.md)
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import sqlite3
15
+ import sys
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+ from importlib import resources
18
+ from pathlib import Path
19
+ from urllib.parse import urlsplit
20
+
21
+ HOME = Path(os.environ.get("TINYAPPS_HOME", Path.home() / "tinyapps"))
22
+ PORT = int(os.environ.get("TINYAPPS_PORT", "8080"))
23
+ NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
24
+ KINDS = ("apps", "artifacts")
25
+ MAX_BODY = 1_000_000
26
+
27
+ FRAME_CSP = "sandbox allow-scripts allow-downloads; connect-src 'none'; form-action 'none'"
28
+ ARTIFACT_CSP = "sandbox allow-scripts allow-downloads; form-action 'none'"
29
+
30
+ SDK = """<script>
31
+ window.app = (() => {
32
+ let next = 0;
33
+ const pending = new Map();
34
+ addEventListener("message", (e) => {
35
+ if (e.source !== parent || !e.data || e.data.type !== "tinyapps:result") return;
36
+ const p = pending.get(e.data.id);
37
+ if (!p) return;
38
+ pending.delete(e.data.id);
39
+ if (e.data.error) p.reject(new Error(e.data.error));
40
+ else p.resolve(e.data.rows);
41
+ });
42
+ return {
43
+ name: %(name)s,
44
+ sql(sql, params = []) {
45
+ return new Promise((resolve, reject) => {
46
+ const id = ++next;
47
+ pending.set(id, { resolve, reject });
48
+ parent.postMessage({ type: "tinyapps:sql", id, sql, params }, "*");
49
+ });
50
+ },
51
+ };
52
+ })();
53
+ </script>
54
+ """
55
+
56
+ HOST_PAGE = """<!doctype html>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <title>%(title)s</title>
60
+ <style>html, body, iframe { margin: 0; border: 0; width: 100%%; height: 100%%; display: block; }</style>
61
+ <iframe sandbox="allow-scripts allow-downloads" src="/apps/%(title)s/frame"></iframe>
62
+ <script>
63
+ const frame = document.querySelector("iframe");
64
+ addEventListener("message", async (e) => {
65
+ if (e.source !== frame.contentWindow || !e.data || e.data.type !== "tinyapps:sql") return;
66
+ const { id, sql, params } = e.data;
67
+ let reply;
68
+ try {
69
+ const res = await fetch(%(api)s, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ sql, params }),
73
+ });
74
+ const body = await res.json();
75
+ reply = res.ok ? { id, rows: body.rows } : { id, error: body.error || res.statusText };
76
+ } catch (err) {
77
+ reply = { id, error: String(err) };
78
+ }
79
+ frame.contentWindow.postMessage({ type: "tinyapps:result", ...reply }, "*");
80
+ });
81
+ </script>
82
+ """
83
+
84
+
85
+ def page_path(kind, name):
86
+ return HOME / "web" / kind / f"{name}.html"
87
+
88
+
89
+ def db_path(name):
90
+ return HOME / "data" / f"{name}.sqlite"
91
+
92
+
93
+ def page_exists(kind, name):
94
+ return kind in KINDS and bool(NAME.fullmatch(name)) and page_path(kind, name).is_file()
95
+
96
+
97
+ def _deny_attach(action, *_):
98
+ if action in (sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH):
99
+ return sqlite3.SQLITE_DENY
100
+ return sqlite3.SQLITE_OK
101
+
102
+
103
+ def run_sql(name, sql, params):
104
+ """Run one statement against the app's database and return its rows."""
105
+ db_path(name).parent.mkdir(parents=True, exist_ok=True)
106
+ con = sqlite3.connect(db_path(name), timeout=5)
107
+ try:
108
+ con.execute("PRAGMA journal_mode=WAL")
109
+ con.set_authorizer(_deny_attach)
110
+ con.row_factory = sqlite3.Row
111
+ with con:
112
+ return [dict(row) for row in con.execute(sql, params).fetchall()]
113
+ finally:
114
+ con.close()
115
+
116
+
117
+ def inject(page, script):
118
+ """Insert script at the start of <head>, else after the doctype, else at the top."""
119
+ for pattern in (r"<head\b[^>]*>", r"<!doctype[^>]*>"):
120
+ m = re.search(pattern, page, re.I)
121
+ if m:
122
+ return page[: m.end()] + script + page[m.end() :]
123
+ return script + page
124
+
125
+
126
+ def listing(kind):
127
+ names = sorted(p.stem for p in (HOME / "web" / kind).glob("*.html") if NAME.fullmatch(p.stem))
128
+ items = "".join(f'<li><a href="/{kind}/{n}">{n}</a></li>' for n in names) or "<li>none yet</li>"
129
+ return f'<!doctype html><meta charset="utf-8"><title>{kind}</title><h1>{kind}</h1><ul>{items}</ul>'
130
+
131
+
132
+ def delete(kind, name):
133
+ if kind not in KINDS or not NAME.fullmatch(name):
134
+ sys.exit(f"invalid kind or name: {kind} {name}")
135
+ page = page_path(kind, name)
136
+ if not page.is_file():
137
+ sys.exit(f"no such {kind[:-1]}: {name}")
138
+ page.unlink()
139
+ if kind == "apps":
140
+ for suffix in ("", "-wal", "-shm"):
141
+ Path(f"{db_path(name)}{suffix}").unlink(missing_ok=True)
142
+ print(f"deleted {kind[:-1]} {name}")
143
+
144
+
145
+ class Handler(BaseHTTPRequestHandler):
146
+ def send(self, status, body, ctype="text/html; charset=utf-8", csp=None):
147
+ data = body.encode() if isinstance(body, str) else body
148
+ self.send_response(status)
149
+ self.send_header("Content-Type", ctype)
150
+ self.send_header("Content-Length", str(len(data)))
151
+ self.send_header("Cache-Control", "no-store")
152
+ self.send_header("X-Content-Type-Options", "nosniff")
153
+ if csp:
154
+ self.send_header("Content-Security-Policy", csp)
155
+ self.end_headers()
156
+ self.wfile.write(data)
157
+
158
+ def send_json(self, status, obj):
159
+ self.send(status, json.dumps(obj), "application/json")
160
+
161
+ def parts(self):
162
+ return urlsplit(self.path).path.strip("/").split("/")
163
+
164
+ def do_GET(self):
165
+ match self.parts():
166
+ case [""]:
167
+ self.send(200, '<!doctype html><meta charset="utf-8"><title>tinyapps</title>'
168
+ '<ul><li><a href="/apps">apps</a></li><li><a href="/artifacts">artifacts</a></li></ul>')
169
+ case [kind] if kind in KINDS:
170
+ self.send(200, listing(kind))
171
+ case ["artifacts", name] if page_exists("artifacts", name):
172
+ self.send(200, page_path("artifacts", name).read_bytes(), csp=ARTIFACT_CSP)
173
+ case ["apps", name] if page_exists("apps", name):
174
+ self.send(200, HOST_PAGE % {"title": name, "api": json.dumps(f"/api/apps/{name}/sql")})
175
+ case ["apps", name, "frame"] if page_exists("apps", name):
176
+ page = page_path("apps", name).read_text(encoding="utf-8")
177
+ self.send(200, inject(page, SDK % {"name": json.dumps(name)}), csp=FRAME_CSP)
178
+ case _:
179
+ self.send_json(404, {"error": "not found"})
180
+
181
+ def do_POST(self):
182
+ match self.parts():
183
+ case ["api", "apps", name, "sql"] if page_exists("apps", name):
184
+ self.handle_sql(name)
185
+ case _:
186
+ self.send_json(404, {"error": "not found"})
187
+
188
+ def handle_sql(self, name):
189
+ # Only the host page may call the API; sandboxed pages send Origin "null". Compare
190
+ # hosts, not schemes, so it keeps working behind a TLS-terminating proxy.
191
+ if urlsplit(self.headers.get("Origin", "")).netloc != self.headers.get("Host"):
192
+ return self.send_json(403, {"error": "forbidden origin"})
193
+ if not self.headers.get("Content-Type", "").startswith("application/json"):
194
+ return self.send_json(415, {"error": "expected application/json"})
195
+ try:
196
+ length = int(self.headers.get("Content-Length") or 0)
197
+ if not 0 <= length <= MAX_BODY:
198
+ return self.send_json(413, {"error": "request too large"})
199
+ body = json.loads(self.rfile.read(length))
200
+ sql, params = body["sql"], body.get("params", [])
201
+ if not isinstance(sql, str) or not isinstance(params, (list, dict)):
202
+ raise ValueError("sql must be a string and params a list or object")
203
+ except (ValueError, KeyError, TypeError) as exc:
204
+ return self.send_json(400, {"error": f"bad request: {exc}"})
205
+ try:
206
+ self.send_json(200, {"rows": run_sql(name, sql, params)})
207
+ except sqlite3.Error as exc:
208
+ self.send_json(400, {"error": str(exc)})
209
+ except Exception as exc:
210
+ self.log_error("sql failed for %s: %r", name, exc)
211
+ self.send_json(500, {"error": str(exc)})
212
+
213
+
214
+ def main():
215
+ if sys.argv[1:2] == ["delete"] and len(sys.argv) == 4:
216
+ return delete(sys.argv[2], sys.argv[3])
217
+ if sys.argv[1:] == ["skill"]:
218
+ return print(resources.files("tinyapps").joinpath("SKILL.md").read_text(), end="")
219
+ if len(sys.argv) > 1:
220
+ sys.exit(__doc__)
221
+ for kind in KINDS:
222
+ (HOME / "web" / kind).mkdir(parents=True, exist_ok=True)
223
+ (HOME / "data").mkdir(parents=True, exist_ok=True)
224
+ print(f"tinyapps serving {HOME} on :{PORT}", flush=True)
225
+ ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinyapps
3
+ Version: 0.1.0
4
+ Summary: An extremely minimal, self-hosted, stateful app builder your agents can use.
5
+ Author-email: Arjun Mahishi <arjun.mahishi@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/arjunmahishi/tinyapps
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # tinyapps
14
+
15
+ An extremely minimal, self-hosted, stateful app builder your agents can use.
16
+
17
+ Ask your agent for a tool. It writes one HTML file, and if the app needs to
18
+ remember things, a SQLite database next to it. The page is live at a URL on
19
+ your own machine as soon as the file exists. No framework, no build step, no
20
+ deploy: one Python file (standard library only) and a skill that teaches your
21
+ agent the rules.
22
+
23
+ - **Apps** at `/apps/<name>`: an HTML page with its own SQLite database.
24
+ - **Artifacts** at `/artifacts/<name>`: a static page (report, chart, mockup).
25
+
26
+ ## Alternatives
27
+
28
+ There are plenty, and most do far more. Pick tinyapps only if you want it
29
+ small, on your own hardware, and driven by your own agent.
30
+
31
+ - **[Claude Artifacts](https://support.anthropic.com/en/articles/9487310-what-are-artifacts-and-how-do-i-use-them) / [ChatGPT Canvas](https://openai.com/index/introducing-canvas/)**: pages and small apps the assistant builds inside a chat. Hosted by the vendor and tied to their chat product.
32
+ - **[Lovable](https://lovable.dev), [Bolt.new](https://bolt.new), [v0](https://v0.app), [Replit Agent](https://replit.com/products/agent)**: describe an app and get a complete full-stack project, built and hosted on their cloud.
33
+ - **[Val Town](https://www.val.town)**: a hosted platform that instantly deploys small TypeScript apps and APIs, with built-in SQLite. Made for people and their agents.
34
+ - **[PocketBase](https://pocketbase.io)**: an open-source backend in one file (database, auth, file storage, admin UI) that you self-host. You or your agent still build and serve the frontend.
35
+
36
+ ## How it works
37
+
38
+ ![How tinyapps works](https://raw.githubusercontent.com/arjunmahishi/tinyapps/main/docs/how-it-works.png)
39
+
40
+ - Your agent writes `~/tinyapps/web/apps/<name>.html` and creates the
41
+ database `~/tinyapps/data/<name>.sqlite` directly with `sqlite3`.
42
+ - The browser loads a host page that runs your HTML in a sandboxed iframe. App
43
+ code can't make network requests; its only way out is
44
+ `await app.sql(sql, params)`, which the host relays to the server.
45
+ - The server picks the database from the app name in the URL, never from the
46
+ request. Databases are never served over HTTP.
47
+ - Code and content are separate: this repo is the server; your pages and data
48
+ live in `~/tinyapps/` and never end up in git.
49
+
50
+ ## Run it
51
+
52
+ Requires Python 3.10+.
53
+
54
+ ```bash
55
+ pipx install tinyapps
56
+ tinyapps # serves ~/tinyapps on :8080
57
+ ```
58
+
59
+ Then give your agent the skill, printed by `tinyapps skill`. For Hermes:
60
+
61
+ ```bash
62
+ f=~/.hermes/skills/productivity/tinyapps/SKILL.md
63
+ mkdir -p "${f%/*}" && [ -e "$f" ] || tinyapps skill > "$f"
64
+ ```
65
+
66
+ The agent asks for your base URL on first use. It refines its copy of the
67
+ skill over time, so the command above never overwrites it. After
68
+ `pipx upgrade tinyapps`, compare it with `tinyapps skill` and merge any
69
+ changes by hand.
70
+ The skill is plain Markdown, so any agent that can write files can use it.
71
+
72
+ > **tinyapps has no login.** Anyone who can reach it can open every page and
73
+ > read or change every app's data. Keep it on a trusted network or behind an
74
+ > authenticating proxy.
75
+
76
+ ## How I use it
77
+
78
+ This is my setup, not a requirement. Any agent, machine or proxy works.
79
+
80
+ ![My setup](https://raw.githubusercontent.com/arjunmahishi/tinyapps/main/docs/my-setup.png)
81
+
82
+ - **Machine**: a 10-year-old Dell Inspiron 3541 laptop (AMD A6-6310, 4 GB
83
+ RAM, spinning 500 GB disk) running Debian 13.
84
+ - **Agent**: [Hermes Agent](https://github.com/NousResearch/hermes-agent). I
85
+ ask it for apps from chat; it writes them and replies with the link.
86
+ - **Service**: a systemd user unit tied to Hermes, so it
87
+ starts and stops with the agent.
88
+ - **Remote access**: a Cloudflare Tunnel publishes it on a subdomain, with
89
+ Cloudflare Access (Google login, my email only) in front. At home I also
90
+ reach it directly over the LAN.
@@ -0,0 +1,9 @@
1
+ tinyapps/SKILL.md,sha256=7VB6HmfRdh1KA71IIElrOrqo4G5yBv3YmqRozvMno04,1291
2
+ tinyapps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ tinyapps/server.py,sha256=A2kwdnd3iLKiT8XCx8LICHlPWRrghq9SmbBUcDmvDdY,8820
4
+ tinyapps-0.1.0.dist-info/licenses/LICENSE,sha256=YU8uus4cKNYR3S3S6jlflmcs-iNgiANUjNhYFKghdNA,1070
5
+ tinyapps-0.1.0.dist-info/METADATA,sha256=4IcN-nh5xElNBIWxqItgbqpPkjBBAcNw3az2kbz8lQM,4236
6
+ tinyapps-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ tinyapps-0.1.0.dist-info/entry_points.txt,sha256=gmeUwcPtci1stcZWaZFuM-2UHaXqLdFZNstWZv8Jo88,50
8
+ tinyapps-0.1.0.dist-info/top_level.txt,sha256=V4Uo-2AHsY_DHImB4Ws5gocu5muESHdeYeR8Pc-Sgbc,9
9
+ tinyapps-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tinyapps = tinyapps.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arjun Mahishi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ tinyapps