Getting Started

Install, configure a _test database, boot the FastAPI app, and run your first scidex.list verb via curl + in-process.

Source: docs/tutorials/getting-started.md

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: psql and curl on PATH for 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:

  1. The substrate uses psycopg3’s async pool with a request-scoped ContextVar. You acquire a connection via database.acquire() and set it on db_context for the duration of the verb. The verb body reads back via ctx.db() and raises RuntimeError("no DB connection in current request scope") if you forgot.

  2. Context is server-stamped — agents never construct one in production. The FastAPI handler builds it from JWT / API key / cookie (see _build_context in api/app.py). In tests and REPL use, you construct it directly with an ActorRef.

  3. invoke() validates args against the verb’s input model, calls the underlying coroutine, and returns the dumped output dict. If validation fails you get a pydantic.ValidationError; if the verb raises SubstrateException you 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.