tinyapps 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- tinyapps-0.1.0/PKG-INFO +90 -0
- tinyapps-0.1.0/README.md +78 -0
- tinyapps-0.1.0/pyproject.toml +25 -0
- tinyapps-0.1.0/setup.cfg +4 -0
- tinyapps-0.1.0/tinyapps/SKILL.md +26 -0
- tinyapps-0.1.0/tinyapps/__init__.py +0 -0
- tinyapps-0.1.0/tinyapps/server.py +229 -0
- tinyapps-0.1.0/tinyapps.egg-info/PKG-INFO +90 -0
- tinyapps-0.1.0/tinyapps.egg-info/SOURCES.txt +11 -0
- tinyapps-0.1.0/tinyapps.egg-info/dependency_links.txt +1 -0
- tinyapps-0.1.0/tinyapps.egg-info/entry_points.txt +2 -0
- tinyapps-0.1.0/tinyapps.egg-info/top_level.txt +1 -0
tinyapps-0.1.0/LICENSE
ADDED
|
@@ -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.
|
tinyapps-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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.
|
tinyapps-0.1.0/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# tinyapps
|
|
2
|
+
|
|
3
|
+
An extremely minimal, self-hosted, stateful app builder your agents can use.
|
|
4
|
+
|
|
5
|
+
Ask your agent for a tool. It writes one HTML file, and if the app needs to
|
|
6
|
+
remember things, a SQLite database next to it. The page is live at a URL on
|
|
7
|
+
your own machine as soon as the file exists. No framework, no build step, no
|
|
8
|
+
deploy: one Python file (standard library only) and a skill that teaches your
|
|
9
|
+
agent the rules.
|
|
10
|
+
|
|
11
|
+
- **Apps** at `/apps/<name>`: an HTML page with its own SQLite database.
|
|
12
|
+
- **Artifacts** at `/artifacts/<name>`: a static page (report, chart, mockup).
|
|
13
|
+
|
|
14
|
+
## Alternatives
|
|
15
|
+
|
|
16
|
+
There are plenty, and most do far more. Pick tinyapps only if you want it
|
|
17
|
+
small, on your own hardware, and driven by your own agent.
|
|
18
|
+
|
|
19
|
+
- **[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.
|
|
20
|
+
- **[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.
|
|
21
|
+
- **[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.
|
|
22
|
+
- **[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.
|
|
23
|
+
|
|
24
|
+
## How it works
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
- Your agent writes `~/tinyapps/web/apps/<name>.html` and creates the
|
|
29
|
+
database `~/tinyapps/data/<name>.sqlite` directly with `sqlite3`.
|
|
30
|
+
- The browser loads a host page that runs your HTML in a sandboxed iframe. App
|
|
31
|
+
code can't make network requests; its only way out is
|
|
32
|
+
`await app.sql(sql, params)`, which the host relays to the server.
|
|
33
|
+
- The server picks the database from the app name in the URL, never from the
|
|
34
|
+
request. Databases are never served over HTTP.
|
|
35
|
+
- Code and content are separate: this repo is the server; your pages and data
|
|
36
|
+
live in `~/tinyapps/` and never end up in git.
|
|
37
|
+
|
|
38
|
+
## Run it
|
|
39
|
+
|
|
40
|
+
Requires Python 3.10+.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install tinyapps
|
|
44
|
+
tinyapps # serves ~/tinyapps on :8080
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then give your agent the skill, printed by `tinyapps skill`. For Hermes:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
f=~/.hermes/skills/productivity/tinyapps/SKILL.md
|
|
51
|
+
mkdir -p "${f%/*}" && [ -e "$f" ] || tinyapps skill > "$f"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The agent asks for your base URL on first use. It refines its copy of the
|
|
55
|
+
skill over time, so the command above never overwrites it. After
|
|
56
|
+
`pipx upgrade tinyapps`, compare it with `tinyapps skill` and merge any
|
|
57
|
+
changes by hand.
|
|
58
|
+
The skill is plain Markdown, so any agent that can write files can use it.
|
|
59
|
+
|
|
60
|
+
> **tinyapps has no login.** Anyone who can reach it can open every page and
|
|
61
|
+
> read or change every app's data. Keep it on a trusted network or behind an
|
|
62
|
+
> authenticating proxy.
|
|
63
|
+
|
|
64
|
+
## How I use it
|
|
65
|
+
|
|
66
|
+
This is my setup, not a requirement. Any agent, machine or proxy works.
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
- **Machine**: a 10-year-old Dell Inspiron 3541 laptop (AMD A6-6310, 4 GB
|
|
71
|
+
RAM, spinning 500 GB disk) running Debian 13.
|
|
72
|
+
- **Agent**: [Hermes Agent](https://github.com/NousResearch/hermes-agent). I
|
|
73
|
+
ask it for apps from chat; it writes them and replies with the link.
|
|
74
|
+
- **Service**: a systemd user unit tied to Hermes, so it
|
|
75
|
+
starts and stops with the agent.
|
|
76
|
+
- **Remote access**: a Cloudflare Tunnel publishes it on a subdomain, with
|
|
77
|
+
Cloudflare Access (Google login, my email only) in front. At home I also
|
|
78
|
+
reach it directly over the LAN.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tinyapps"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An extremely minimal, self-hosted, stateful app builder your agents can use."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Arjun Mahishi", email = "arjun.mahishi@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/arjunmahishi/tinyapps"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
tinyapps = "tinyapps.server:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["tinyapps"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
tinyapps = ["SKILL.md"]
|
tinyapps-0.1.0/setup.cfg
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`.
|
|
File without changes
|
|
@@ -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
|
+

|
|
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
|
+

|
|
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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tinyapps/SKILL.md
|
|
5
|
+
tinyapps/__init__.py
|
|
6
|
+
tinyapps/server.py
|
|
7
|
+
tinyapps.egg-info/PKG-INFO
|
|
8
|
+
tinyapps.egg-info/SOURCES.txt
|
|
9
|
+
tinyapps.egg-info/dependency_links.txt
|
|
10
|
+
tinyapps.egg-info/entry_points.txt
|
|
11
|
+
tinyapps.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tinyapps
|