Skip to main content

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] and ANTHROPIC_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

  1. 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_dir to relocate, refresh_schema=True to re-introspect).
  2. 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.
  3. 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.
  4. Execute (ask only). The query runs against the endpoint; execution errors are fed back the same way. The loop is hard-capped by max_iterations (default 5).

Exhausting the cap raises a typed error carrying the last query and errors: QueryValidationError or QueryExecutionError. All lucid failures subclass LucidErrorSchemaError 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

lucid_graphql-0.5.0.tar.gz (21.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lucid_graphql-0.5.0-py3-none-any.whl (26.3 kB view details)

Uploaded Python 3

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

Hashes for lucid_graphql-0.5.0.tar.gz
Algorithm Hash digest
SHA256 179060aecfeba223000785eea68d9b8af24cfd7a6931e8188a72aa4f9fb519e3
MD5 a8e788854ef25e3ec8b3c45c0b824e7f
BLAKE2b-256 03eef0309a3e861659ceba5489dae0abfc2dbb1a438a42ffabba6c6ffcab977a

See more details on using hashes here.

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

Hashes for lucid_graphql-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4f629c774b117f9abc3dffa5ec616bc277f7ec0db8bbbcbac724b6e967d4fb18
MD5 3d76577416cd78c83193b1d4d370a0c3
BLAKE2b-256 6c2559a76e9092c0983cb0f8c64a99d6d4e50f11aa8819598a2e0f7662a274e2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page