Getting Started
Install, configure a _test database, boot the FastAPI app, and run your first scidex.list verb via curl + in-process.
Getting started with the SciDEX v2 Substrate
This tutorial gets you from git clone to your first verb call in
about ten minutes. It assumes:
-
Python ≥ 3.11 (the package declares this in
pyproject.toml) -
A local Postgres ≥ 14 you can create databases on
-
Optional:
psqlandcurlonPATHfor the verification steps
We use a _test database throughout. The substrate’s tests/conftest.py
guard refuses to run pytest against scidex or scidex_v2 and against
any database whose name doesn’t match *[_-]test[_-]* — the same
discipline applies to ad-hoc development unless you are explicitly
working on the live scidex_v2 deployment.
1. Clone and install
git clone https://github.com/SciDEX-AI/scidex-substrate.git
cd SciDEX-Substrate
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev,mcp]"
The [dev] extra gives you pytest, mypy, ruff, and httpx. The [mcp]
extra installs the mcp Python package so you can run the MCP server
locally. See pyproject.toml for other extras (s3, otel, k8s,
shredder).
If you are working in parallel with other agents (multiple pip install -e .
runs against the same Python interpreter), read the “Multi-worker concurrent
install footgun” section of AGENTS.md. The short answer
is to use a per-task virtualenv or pin PYTHONPATH=$PWD/src so the shared
__editable__.scidex_substrate-0.0.1.pth file in site-packages can’t point
your tests at another worker’s source tree.
2. Create a _test database
The substrate uses psycopg3 with an async connection pool. Any DSN
psycopg accepts works, but the database name must end in _test for
pytest and for any local exploration that uses the same guard helpers.
createdb scidex_v2_dev_test
export SCIDEX_DSN=postgresql:///scidex_v2_dev_test
If you want to also run the test suite later:
createdb scidex_v2_test
export SCIDEX_TEST_DSN=postgresql:///scidex_v2_test
SCIDEX_DSN is the runtime DSN; SCIDEX_TEST_DSN overrides it
specifically for pytest. The pytest guard refuses both scidex and
scidex_v2 (the live v1 and v2 databases — see the deployment map in
AGENTS.md) and requires a _test suffix.
3. Boot the FastAPI app
Migrations are applied automatically at startup unless you disable them. The first boot creates ~120 tables; expect 5-30 seconds depending on disk.
uvicorn scidex_substrate.api.app:create_app \
--factory \
--host 127.0.0.1 \
--port 8200 \
--reload
You should see something like:
INFO: auto-migrate applied: ['0001_initial_schema.sql', '0002_…', …]
INFO: schema seed: {'inserted': 137, 'updated': 0, 'skipped': 0}
INFO: native-skill seed: {'inserted': 12, 'updated': 0, 'skipped': 0}
INFO: Uvicorn running on http://127.0.0.1:8200
If startup aborts with a RuntimeError: Startup aborted: 1 migration(s) failed: line, the SCIDEX_STARTUP_REQUIRE_MIGRATIONS=1 default has
saved you from running with a partial schema. Inspect the named file
in src/scidex_substrate/migrations/, fix the problem, and re-run.
For dev-only override, set SCIDEX_STARTUP_REQUIRE_MIGRATIONS=0 —
/readyz will then return 503 with the structured failure detail.
Useful startup environment variables (full list in .env.example):
| Var | Default | Effect |
|---|---|---|
SCIDEX_DSN |
unset | The Postgres connection string the pool opens. Required. |
SCIDEX_AUTO_MIGRATE |
1 |
Apply pending migrations on boot. Set 0 in production where you run migrations out-of-band. |
SCIDEX_AUTO_SEED_SCHEMAS |
1 |
Auto-seed default_schemas/*.json into the schema_registry table. |
SCIDEX_AUTO_SEED_SKILLS |
1 |
Auto-seed skills/* bundles into the substrate_skills table. |
SCIDEX_AUTO_SEED_TOOLS |
1 |
Auto-seed tools/*.yaml into tools. |
SCIDEX_DEV_AUTH |
1 |
Enable email-only dev signup at /auth/dev/login. Must be 0 in production. |
SCIDEX_JWT_SECRET |
random | JWT signing key. Set a long random string in any persistent deployment. |
4. Probe /health and /health/deep
The substrate ships two health endpoints. /health is the cheap
liveness probe; /health/deep is the operator-facing report.
curl -s http://127.0.0.1:8200/health | jq
{
"status": "ok",
"version": "0.0.1",
"verb_count": 217
}
curl -s http://127.0.0.1:8200/health/deep | jq
You’ll get a much richer payload: git SHA, last applied migration,
artifact count, agent count, dev-auth flag, OAuth configuration, and
the persona-runner subsystem state. If any sub-probe fails, the
top-level status flips to "degraded" and a degraded list names
the failing subsystems — but the HTTP status stays 200 so a single
flaky probe doesn’t bounce the pod. Use /readyz (returns 503 on
partial schema) for load-balancer health checks; see
deploying-substrate.md for the LB rationale.
5. Call your first verb (curl)
The verb registry is exposed at / and as one POST route per verb under
/api/scidex/.... List the registry:
curl -s http://127.0.0.1:8200/ | jq '.verbs | length, .verbs[:5]'
217
[
"scidex.agents.create",
"scidex.agents.get",
"scidex.agents.list",
...
]
(Verbs were pluralized in the 2026-05-20 unification session;
scidex.actor.* is the historical singular form.)
Now hit scidex.list, which is polymorphic across every registered
artifact type:
curl -s http://127.0.0.1:8200/api/scidex/list \
-X POST \
-H 'content-type: application/json' \
-d '{"type": "hypothesis", "limit": 3}' | jq
If your local DB has no hypotheses yet, the response is:
{
"page": {
"items": [],
"cursor": null,
"total": 0
}
}
scidex.list is decorated with read=True, public=True. The public
flag means the route is accessible without a Bearer token but
PublicRouteMiddleware enforces a 60 req/min per-IP sliding window
and stamps X-Rate-Limit-* headers on the response (see
scidex_substrate/skill/framework.py
for the decorator surface).
For a write verb, you need an authenticated Context. Dev-mode signup
will mint a JWT in a couple of HTTP calls:
# Mint a dev JWT (only works while SCIDEX_DEV_AUTH=1)
curl -s -c cookies.txt -X POST \
http://127.0.0.1:8200/auth/dev/login \
-H 'content-type: application/json' \
-d '{"email": "you@example.com"}'
# Create a hypothesis. (Requires the actor to exist or to be allowed
# to self-create; on a fresh DB you may need to seed an actor first.)
curl -s -b cookies.txt -X POST \
http://127.0.0.1:8200/api/scidex/create \
-H 'content-type: application/json' \
-d '{
"type": "hypothesis",
"content": {
"title": "LRRK2 inhibition reduces alpha-synuclein aggregation",
"claim": "Selective LRRK2 kinase inhibition slows Lewy-body formation in midbrain organoids."
}
}' | jq
On success you get a WriteResult:
{
"ref": {"type": "hypothesis", "id": "h-7c1c…", "version": null},
"content_hash": "sha256:…",
"version_number": 1,
"events_emitted": [421]
}
Errors follow a standard envelope (see SPEC-001 §14):
{
"error": {"code": "validation_failed", "message": "request validation failed"},
"details": {"…field-level path/constraint/value detail…"},
"request_id": "…",
"retryable": false,
"retry_after_ms": null
}
The full mapping from substrate error code to HTTP status lives in
_status_for_code in api/app.py.
6. Call a verb in-process from Python
For tests, REPL sessions, and tools that already share a Python process
with the substrate, skip HTTP entirely. The framework’s invoke()
helper does validate-call-serialize in one shot.
import asyncio
from scidex_substrate.core import database
from scidex_substrate.skill.framework import invoke
from scidex_substrate.skill.types import ActorRef, Context, db_context
async def main() -> None:
# Open the pool the same way FastAPI does at startup.
await database.open_pool()
try:
async with database.acquire() as conn:
token = db_context.set(conn)
try:
ctx = Context(
actor=ActorRef(id="anonymous", kind="ai_local",
permissions=["viewer"]),
session_id="repl",
)
page = await invoke(
"scidex.list",
{"type": "hypothesis", "limit": 3},
ctx,
)
for item in page["page"]["items"]:
print(item["ref"], "-", item["title"])
finally:
db_context.reset(token)
finally:
await database.close_pool()
asyncio.run(main())
A few things to note from this snippet — they’re the contract for any in-process verb call:
-
The substrate uses
psycopg3’s async pool with a request-scopedContextVar. You acquire a connection viadatabase.acquire()and set it ondb_contextfor the duration of the verb. The verb body reads back viactx.db()and raisesRuntimeError("no DB connection in current request scope")if you forgot. -
Contextis server-stamped — agents never construct one in production. The FastAPI handler builds it from JWT / API key / cookie (see_build_contextinapi/app.py). In tests and REPL use, you construct it directly with anActorRef. -
invoke()validatesargsagainst the verb’s input model, calls the underlying coroutine, and returns the dumped output dict. If validation fails you get apydantic.ValidationError; if the verb raisesSubstrateExceptionyou get it back unwrapped.
See SPEC-001 §3.1 (generation pipeline)
for the three-output design: the same @verb-decorated function gives
you the HTTP route, the MCP tool, and the in-process invoke() entry.
7. Optional: run the MCP server
The MCP server exposes the same verb registry over stdio JSON-RPC, for Claude Code / Codex / Orchestra agents:
scidex-substrate-mcp
Or wire it into an MCP-aware client:
{
"mcpServers": {
"scidex": {
"command": "python",
"args": ["-m", "scidex_substrate.skill.mcp_server"]
}
}
}
The full schema for every tool is served at
GET /v1/mcp/schema.json (no auth required), which is also how
Prism’s TypeScript client generates typed bindings.
What you have now
A running substrate with auto-applied migrations, a seeded schema registry, and three working entry surfaces (HTTP, MCP, in-process). Next, writing-a-verb.md shows you how to add a new polymorphic operation; designing-a-schema.md shows you how to add a new artifact type.
Troubleshooting
psycopg.OperationalError: connection failed. Your SCIDEX_DSN is
wrong or Postgres isn’t listening. Try psql "$SCIDEX_DSN" -c 'select 1'.
Refusing to run tests against non-test SciDEX database. The pytest
guard caught a non-_test DSN. Rename your DB to end in _test or set
SCIDEX_TEST_DSN to a _test DSN before running pytest. This is
intentional and SCIDEX_TEST_DSN_ALLOW_PROD is ignored (see
tests/conftest.py).
RuntimeError: no DB connection in current request scope. You
called a verb in-process without setting db_context. Wrap the call
in async with database.acquire() as conn: + db_context.set(conn).
Startup aborted: 1 migration(s) failed:. A migration file in
src/scidex_substrate/migrations/ errored. Read the structured failure
under /readyz once you set SCIDEX_STARTUP_REQUIRE_MIGRATIONS=0, fix
the SQL, and restart.
verb 'scidex.foo' already registered. Two import paths registered
the same verb name. Usually a duplicated file under skill/verbs/.
All POSTs return {"detail": [{"loc": ["query","body"], "msg": "Field required"}]}. This is the FastAPI string-annotation footgun
documented at the top of _make_handler in api/app.py; if you wrote
a custom router that bypasses attach_verb_routes, you need the
handler.__annotations__["body"] = spec.input_model trick.