Turn natural-language questions into valid GraphQL queries with an agentic LLM workflow.
Project description
lucid
lucid turns a natural-language question into a valid GraphQL query — and, if you want, executes it and hands you the data.
import lucid
SpaceX = "https://spacex-production.up.railway.app/"
response = lucid.ask("the 5 latest launches and their rocket names", url=SpaceX)
response.data # dict — the raw GraphQL `data` payload
response.query # str — the generated GraphQL query that produced it
response.errors # list — GraphQL errors, if any
Prefer a natural-language answer to the raw data? Pass summarize=True and the
model turns the result into prose on response.summary (the data is still
available on response.data):
response = lucid.ask("how many launches succeeded in 2020?", url=SpaceX, summarize=True)
response.summary # str — e.g. "There were 26 launches in 2020, all successful."
Query generation is driven by an agentic workflow built on the Strands Agents SDK. The agent authors the query by navigating the schema through tools — searching it, inspecting individual types, validating candidates — so the full schema never enters the model context. That is what makes lucid cheap and scalable on very large schemas.
Install
uv add lucid-graphql # or: pip install lucid-graphql
uv add "lucid-graphql[litellm]" # to select models by LiteLLM id string
Command line
Ask an endpoint a question straight from the terminal — no install needed with
uvx (the --from form pulls the Anthropic provider extra; set
ANTHROPIC_API_KEY):
uvx --from "lucid-graphql[anthropic]" lucid \
"the 5 latest launches and their rocket names" \
--url https://spacex-production.up.railway.app/
# or install it as a tool:
uv tool install "lucid-graphql[anthropic]"
lucid "the 5 latest launches and their rocket names" --url https://spacex-production.up.railway.app/
The result data prints as JSON on stdout. --generate/-g prints the generated
query instead of executing it; --summarize/-s prints a natural-language answer
instead of the JSON data; --verbose/-v streams the agent's progress (thinking,
tool calls, attempts) to stderr, so stdout stays pipeable:
lucid "electric SUVs under \$60k" --url $CARS -v | jq '.cars[].model.name'
Usage
Generate a query without executing it:
query: str = lucid.generate("the 5 latest launches and their rocket names", url=SpaceX)
Custom HTTP headers (auth tokens, etc.) are honoured on every request lucid makes — introspection and execution alike:
response = lucid.ask(
"my recent orders",
url="https://api.example.com/graphql",
headers={"Authorization": "Bearer <token>"},
)
Create a client that closes over the endpoint and model configuration:
client = lucid.create(
url=SpaceX,
headers={"Authorization": "Bearer <token>"},
model="openrouter/anthropic/claude-3.5-sonnet",
)
client.ask("...")
client.generate("...")
Steering generation with instructions
Applications embedding lucid can append domain guidance to the agent's system
prompt with instructions=. It steers how queries are written — conventions,
field preferences, limits — while the workflow contract (schema navigation,
mandatory validation) stays intact and cannot be overridden:
client = lucid.create(
url=API,
instructions=(
"Always include the id field on every record.\n"
"'latest' or 'newest' means orderBy: YEAR_DESC.\n"
"Never fetch more than 50 items in one request."
),
)
MCP server (extendable)
Expose a lucid client to any MCP-speaking assistant with a FastMCP server
(install the extra: uv add "lucid-graphql[mcp]"). The server ships a minimal,
well-chosen toolset:
| tool | what it does |
|---|---|
search_schema |
find types and fields by keyword |
get_type |
the full SDL — with documentation — of one named type |
ask |
natural language → generated query → executed → data (or, with summarize=true, a natural-language answer) |
generate |
natural language → query, not executed (optional; can be turned off) |
import lucid
client = lucid.create(url="https://api.example.com/graphql")
server = lucid.create_mcp_server(
client,
instructions="Answer questions about a marketplace of cars and dealerships.",
)
server.run() # stdio by default; see FastMCP for HTTP/SSE transports
The server ships built-in MCP instructions describing the tools; instructions=
says what the underlying system is for and is prepended to them (shown to
clients on connect). Pass enable_generate=False to drop the generate tool
(answer questions only, never hand back raw GraphQL).
create_mcp_server returns the FastMCP instance, so a domain-specific server
can extend it — adding its own tools before running it:
server = lucid.create_mcp_server(client, instructions="Our car marketplace.")
@server.tool
def book_test_drive(car_id: str) -> str:
"""Book a test drive for a car."""
...
server.run()
FastMCP is imported only when you build a server, so the core package stays free of the dependency.
Endpoints with introspection disabled
Many production endpoints disable introspection. Supply the schema directly with
schema= and introspection is never attempted — SDL text, a .graphql/.gql file,
a saved introspection-result JSON file, and a built graphql.GraphQLSchema are all
accepted and auto-detected:
response = lucid.ask(
"the 5 latest cars and their manufacturer names",
url="https://api.example.com/graphql", # still needed to execute
schema="schema.graphql",
headers={"Authorization": "Bearer <token>"},
)
# Generate-only needs no url at all — fully offline.
query = lucid.generate("electric SUVs under $60k", schema="schema.graphql")
An explicit schema wins over the on-disk cache, which wins over live
introspection. If introspection is attempted and the endpoint refuses it, lucid
raises SchemaError with a pointer to schema=.
Watching progress (experimental)
ask_stream is ask with progress events — same pipeline, instrumented. Each event
has a ready-to-print message; kinds are thinking, tool, attempt (one per
validation/execution round, carrying the error that was fed back), and a final
done carrying the Response. The event schema is a prototype and may change.
for event in lucid.ask_stream("the 5 latest cars and their rocket names", url=Cars):
print(event.message)
response = event.response # the final event is kind="done"
# Or skip the loop:
response = lucid.ask_stream(question, url=Cars, on_event=lambda e: print(e.message)).result()
generate_stream is the same for the generate-only path (works offline with
schema=, no url needed): its done event carries the validated query and
.result() returns it as a str.
query = lucid.generate_stream(
"electric SUVs under $60k",
schema="schema.graphql",
on_event=lambda e: print(e.message),
).result()
Choosing a model
Provider choice is one line. model accepts:
-
Nothing — defaults to Anthropic Claude (requires
lucid-graphql[anthropic]andANTHROPIC_API_KEY). -
A string — treated as a LiteLLM model id (requires
lucid-graphql[litellm]); the provider prefix swaps the whole backend:model reads "anthropic/claude-3.5-sonnet"ANTHROPIC_API_KEY"openai/gpt-4o"OPENAI_API_KEY"openrouter/anthropic/claude-3.5-sonnet"OPENROUTER_API_KEY"ollama/llama3"local Ollama, no key -
Any Strands model instance — the escape hatch for full control:
import os from strands.models.litellm import LiteLLMModel model = LiteLLMModel( client_args={ "api_key": os.environ["OPENROUTER_API_KEY"], "base_url": "https://openrouter.ai/api/v1", }, model_id="anthropic/claude-3.5-sonnet", params={"temperature": 0}, ) lucid.ask("...", url=SpaceX, model=model)
Temperature defaults to 0 for determinism.
How it works
- Introspect (deterministic, no LLM). The standard introspection query runs once
per endpoint; the schema is converted to SDL and cached on disk, keyed by a hash of
the URL (
cache_dirto relocate,refresh_schema=Trueto re-introspect). - Author (the agent). A Strands agent explores the schema file through tools —
list_root_fields,search_schema,get_type— and drafts a query. It never sees the whole schema. - Validate. The agent must pass
validate_query(graphql-core validation against the real schema) before it can finish; validation errors are fed back for self-correction. - Execute (
askonly). The query runs against the endpoint; execution errors are fed back the same way. The loop is hard-capped bymax_iterations(default 5).
Exhausting the cap raises a typed error carrying the last query and errors:
QueryValidationError or QueryExecutionError. All lucid failures subclass
LucidError — SchemaError for introspection/schema problems and TransportError
when the endpoint can't be reached (connection refused, timeout) — so callers never
see a raw httpx exception.
Testing without a network
Transport is a two-method seam (introspect() / execute()). The default is httpx;
tests inject an in-process transport that runs against a local executable schema:
client = lucid.create(url="local://cars", model=model, transport=my_local_transport)
See tests/cars.py for a complete example — a deliberately large car-marketplace
schema with interfaces, a union, Relay pagination, input filters, and mock data.
The test suite runs every core scenario twice: once through the in-process
transport, and once over real HTTP against an in-process localhost server, so the
default httpx transport (serialization, headers, status handling) is covered too.
Evaluating models
evals/ contains an opt-in bake-off harness (real API keys, spends money, never runs
in CI), built on the Strands Evals SDK:
each question is a strands_evals.Case, one Experiment runs per model, and a
custom result-equivalence Evaluator grades correctness objectively — the generated
query and a hand-written reference execute against the same in-process schema and
their normalized results are compared. Latency, tokens, cost, and correction
iterations are measured over the whole agent trajectory.
just evals # the standard OpenRouter model set (needs OPENROUTER_API_KEY)
just evals "ollama/llama3" 5 # any model list and repeat count
# or drive the runner directly:
uv run python -m evals.runner \
--models "openrouter/anthropic/claude-3.5-sonnet,openai/gpt-4o,openai/gpt-4o-mini,ollama/llama3" \
--repeats 5
The report prints a per-model table and highlights the Pareto frontier across
accuracy, latency, and cost. cost/correct is usually the column to decide by.
Development
Day-to-day tasks are Just recipes:
just sync # uv sync --all-extras
just test # pytest (pass args through: just test -k schema)
just lint # ruff check + format check
just typecheck # ty
just check # lint + typecheck + test
just build # wheel + sdist into dist/
just evals # model bake-off (OpenRouter by default)
One live end-to-end test against the SpaceX API is gated behind
LUCID_LIVE=1 + ANTHROPIC_API_KEY and skipped everywhere else.
Releasing
The version lives in one place (pyproject.toml) and is exposed at runtime as
lucid.__version__. Cutting a release is one command — it runs the full check
suite, bumps the version (SemVer), stamps the CHANGELOG,
tags, publishes to PyPI, and pushes:
just release patch # 0.1.0 -> 0.1.1 (bug fixes)
just release minor # 0.1.0 -> 0.2.0 (backwards-compatible features)
just release major # 0.1.0 -> 1.0.0 (breaking changes)
It needs a clean tree and UV_PUBLISH_TOKEN (loaded from .env). Publish runs
before the push, so a failed upload leaves nothing pushed to recover from. Move
the changes you're releasing into a ## [Unreleased] CHANGELOG section first.
Non-goals (v1)
- Data-frame transformation — that's pluck.
- Mutations and subscriptions.
- CLI or web UI.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lucid_graphql-0.5.0.tar.gz.
File metadata
- Download URL: lucid_graphql-0.5.0.tar.gz
- Upload date:
- Size: 21.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.21 {"installer":{"name":"uv","version":"0.9.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
179060aecfeba223000785eea68d9b8af24cfd7a6931e8188a72aa4f9fb519e3
|
|
| MD5 |
a8e788854ef25e3ec8b3c45c0b824e7f
|
|
| BLAKE2b-256 |
03eef0309a3e861659ceba5489dae0abfc2dbb1a438a42ffabba6c6ffcab977a
|
File details
Details for the file lucid_graphql-0.5.0-py3-none-any.whl.
File metadata
- Download URL: lucid_graphql-0.5.0-py3-none-any.whl
- Upload date:
- Size: 26.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.21 {"installer":{"name":"uv","version":"0.9.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f629c774b117f9abc3dffa5ec616bc277f7ec0db8bbbcbac724b6e967d4fb18
|
|
| MD5 |
3d76577416cd78c83193b1d4d370a0c3
|
|
| BLAKE2b-256 |
6c2559a76e9092c0983cb0f8c64a99d6d4e50f11aa8819598a2e0f7662a274e2
|