Sparkth is a free, open source, extensible, science-driven, AI-first learning platform. It is under active development by Edly.
Sparkth is hosted at https://sparkth.edly.space with the following endpoints:
| Endpoint | URL |
|---|---|
| MCP Server | https://sparkth.edly.space/ai/mcp |
| REST API | https://sparkth.edly.space/api/ |
| Swagger UI | https://sparkth.edly.space/docs |
| ReDoc | https://sparkth.edly.space/redoc |
The published documentation is at edly-io.github.io/sparkth. It covers configuration, user management, the backend and frontend plugin guides, the permissions model, and the Python API reference generated from the code's docstrings.
To build it locally with mkdocs:
make docs # build the site to site/
make docs.serve # live-preview at http://127.0.0.1:8000The REST API is served interactively by the running backend at /docs (Swagger) and
/redoc.
-
Clone the repository:
git clone https://github.com/edly-io/sparkth.git cd sparkth -
Install backend and frontend dependencies:
make backend.install.dev make frontend.install.dev
-
Install git hooks:
make backend.install.dev.githooks
Start dependent services:
make services.up
Apply migrations:
make migrations
Start backend service:
make backend.up.dev
In a separate terminal, start the frontend service (with hot-reload):
make frontend.up.dev
Access the app at http://localhost:3000.
.env is committed with working dev defaults and works out of the box.
For sensitive credentials (Google OAuth, Slack), create a .env.local file — see the comments inside .env for the variables to add there. .env.local takes precedence over .env.
Playwright end-to-end tests live in frontend/e2e-tests/ (the vitest unit suite is the
separate frontend/tests/ mirror). They run against their own
ephemeral SQLite database, created fresh and deleted on every run, so they never
touch your dev Postgres data. The run starts and stops a throwaway backend (on
port 7727) and the frontend for you.
Install the browsers once:
make test.e2e.install
Then, with the backing services up (make services.up, for Mailpit and Redis)
and your dev backend stopped (the run owns port 7727):
make test.e2e # headless
make test.e2e.ui # interactive UI mode
The MCP server is served over HTTP by the running backend. When running the API server locally, it is available at:
http://127.0.0.1:7727/ai/mcp
This allows Claude and other MCP-compatible clients to connect to the MCP server via HTTP.
Once the server is running, you can access the interactive API documentation locally:
- Swagger UI: http://127.0.0.1:7727/docs
- Best for testing endpoints interactively.
- ReDoc: http://127.0.0.1:7727/redoc
- Best for reading documentation structure.
The Sparkth MCP server is served over HTTP by the running backend at /ai/mcp
(e.g. http://127.0.0.1:7727/ai/mcp). Start the backend first (make backend.up.dev),
then bridge Claude Desktop to it with mcp-remote.
Edit the Claude configuration file:
# macOS
~/Library/Application\ Support/Claude/claude_desktop_config.json
# Windows
%APPDATA%\Claude\claude_desktop_config.json
# Linux
~/.config/Claude/claude_desktop_config.json
Add the Sparkth MCP server configuration:
{
"mcpServers": {
"Sparkth": {
"command": "npx",
"args": [
"mcp-remote",
"http://127.0.0.1:7727/ai/mcp"
]
}
}
}Note: You may need to put the full path to the
npxexecutable in the command field. You can get this by runningwhich npxon macOS/Linux orwhere npxon Windows.
Restart Claude Desktop. Ensure that the "Sparkth" tools appear in the "Search and tools" menu. Then start a new chat and generate a course:
Use Sparkth to generate a very short course (~1 hour) on the literary merits of Hamlet, by Shakespeare.
Sparkth will generate a prompt that will help Claude generate this course.
A self-contained single-host deployment lives in
docker-compose.prod.yml: the Sparkth application (with the
frontend bundled), TimescaleDB/Postgres (hosting both the app and analytics databases),
and Redis. Every service loads .env and then .env.local (later entries win);
.env.local is mandatory, and compose fails to start without it.
-
Complete the "MUST change in production" checklist at the top of
.env, placing the overrides in.env.local(git-ignored). Point the connection URLs at the bundled containers, using the same password asPOSTGRES_PASSWORD:DATABASE_URL=postgresql://sparkth:<POSTGRES_PASSWORD>@db:5432/sparkth ANALYTICS_DATABASE_URL=postgresql://sparkth:<POSTGRES_PASSWORD>@db:5432/sparkth_analytics REDIS_URL=redis://redis:6379
-
Build the image with
make docker.build, or let compose pullghcr.io/edly-io/sparkth:latestfrom GHCR (setSPARKTH_TAGto pin a release). -
Start the stack:
docker compose -f docker-compose.prod.yml up -d
-
Apply database migrations, on first start and after every upgrade. One command applies both Alembic lineages (app and analytics) and backfills TimescaleDB continuous aggregates (idempotent):
docker compose -f docker-compose.prod.yml run --rm sparkth python -m sparkth.cli.main migrate
The app is published on port 7727 (SPARKTH_HTTP_PORT to change). TLS and email are
intentionally not part of the stack: run a reverse proxy or load balancer in front of the
published port (and set TRUSTED_PROXY_HOPS to match), and point SMTP_* at a real
provider such as AWS SES. If the proxy runs on the same host, set
SPARKTH_HTTP_BIND=127.0.0.1 so clients cannot bypass it. For orchestrated deployments,
the same image is published to GHCR by CI and runs under Kubernetes.
Runtime configuration is via environment variables in .env (committed dev defaults) and
.env.local (git-ignored overrides); restart the backend after changing them. See the
configuration guide for how to set and update values, the
configuration reference for the variables and feature
flags such as REGISTRATION_ENABLED, and the
user management guide for creating users and resetting
passwords.
Sparkth authorizes actions with a scoped role-based access control (RBAC) model: roles bundle
permissions and are granted at scopes (platform-wide or per-object), to individual users or to
groups — flat, named sets of users whose members all inherit the group's role grants, so
bulk access is one grant instead of one assignment per user. Roles and groups are managed
through the REST API under /api/v1/permissions; user-role assignment and group membership
are managed via the CLI. See the permissions guide.
Sparkth keeps an append-only audit trail of security-relevant and AI actions: who did what, when,
from where, and with what effect. The implementation lives in sparkth/core/audit/ with its public
API in sparkth/lib/audit/; unlike analytics (emitted from a background task, so a failure
surfaces as a logged error rather than blocking the request), audit writes are fail-closed, so a
mutating or AI action whose audit record cannot be written does not proceed. Every AI tool
execution, on every surface (the MCP server, chat, RAG), is recorded as a tool.invoked event
committed before the handler runs plus a tool.completed or tool.failed outcome event, with
redacted arguments and the model identity that drove the call.
Analytics events are validated against versioned schemas before they are stored. Each
schema is a self-describing AnalyticsEventSchema subclass that declares its own
event_type string and integer version, and is registered on the ANALYTICS_EVENTS
hook — the single source of truth the emission gateway resolves against. This mirrors the
permission vocabulary above: declare in code at import time, no separate store and no startup
drain. Import everything from sparkth.lib.analytics, never from sparkth.core.analytics.* directly.
Core events are declared in sparkth.core.analytics; a plugin declares its own from its
__init__ with register_event_schema(self, MyEvent) — the analytics analog of
Permission.create().
from sparkth.lib.analytics import AnalyticsEventSchema, register_event_schema
class CourseCompleted(AnalyticsEventSchema):
event_type = "mycourseplugin.course_completed" # namespaced under the plugin name
version = 1
learner_id: str
course_id: str
# from the plugin's __init__:
register_event_schema(self, CourseCompleted)register_event_schema enforces three guards at import time, so a misconfigured plugin fails
fast at startup instead of at first emit:
- Namespace —
event_typemust be prefixed with the plugin's name, elseEventNamespaceError. This stops a plugin from squatting a core event name or another plugin's namespace. - Collision — any class claiming an already-registered
(event_type, version)raisesDuplicateEventTypeError. Registration is not idempotent: re-registering the same class is fatal too. - Identity — a schema missing
event_type/versionraisesTypeError.
Always declare via
register_event_schema, not by callingANALYTICS_EVENTS.add_itemdirectly — the registration function is what applies the namespace guard. Core events (which carry no plugin prefix) are seeded directly insparkth.core.analytics.
Producers emit server-side through emit_event, which resolves the schema by
(event_type, version), validates the payload against it, and lands one immutable row in the
analytics database. It opens its own analytics session, so callers need no session plumbing:
from sparkth.lib.analytics import emit_event
await emit_event(
"mycourseplugin.course_completed",
1,
{"learner_id": "u1", "course_id": "c1"},
actor_id=str(user.id),
)emit_event catches nothing — UnknownEventTypeError, ValidationError and
SQLAlchemyError all reach the caller, so a broken analytics write is never hidden. Emit
from a FastAPI background task (as the built-in producers do) so the failure surfaces as a
logged unhandled task error after the response has been sent, rather than affecting the
operation being measured.
The lower-level ingest_event is the same validate-and-insert step for a caller that
already holds an analytics session.
Resolve a registered schema by identity with get_event_schema(event_type, version) (raises
UnknownEventTypeError if none is registered).
Contributions are welcome. Open a pull request against main and a maintainer will take a look.
This repository has an automated code review powered by Claude. To request a review on your pull request, post a comment containing @claude-review on the PR. The workflow runs on demand only (it does not run automatically on every push), so use the mention whenever you want a fresh pass, for example after pushing new commits.
This project is licensed under the MIT License — see the LICENSE file for details.