Consuming Descripex Libraries

Copy Markdown View Source

A guide for AI agents and developers who want to discover and call the API of a descripex-powered Elixir library — without reading source or parsing docs.

Why Descripex Libraries Are Worth Finding

Most Elixir libraries require you to read source code, parse docs, and guess at return shapes. A descripex-powered library tells you its entire API contract in structured data — params, types, defaults, errors, return shapes, composition hints — through three function calls.

No doc parsing. No source diving. No guessing.

MyLib.describe()                     # what modules exist?
MyLib.describe(:funding)             # what functions does it have?
MyLib.describe(:funding, :annualize) # tell me everything about this function

Every contract is compile-time validated — if the library compiles, the metadata aligns with actual function signatures and arities. You can trust the structure and focus your effort on behavior.

Detecting Descripex Support

A library uses descripex if any of these are true:

  • function_exported?(SomeModule, :__api__, 0) — module-level introspection
  • function_exported?(MyLib, :describe, 0) — library-level discovery
  • A module's @moduledoc contains an "API Functions" table

When you detect it, use it. The structured metadata is always more reliable than parsing @doc strings.

Discovery Workflow

Three levels, each one step deeper. Start broad, drill into what you need.

Level 1: What Modules Exist?

MyLib.describe()
[%{
  module: MyLib.Funding,       # full module atom
  short_name: :funding,        # use this for Level 2/3 — shorter, friendlier
  namespace: "/funding",       # URL grouping hint (or nil)
  description: "...",          # @moduledoc text (or nil)
  function_count: 3,           # how many public API functions
  annotated?: true             # true = full contracts available
}]

annotated?: true means full contracts (params, returns, errors). false means only basic @doc/@spec info — still useful, just less structured.

Level 2: What Functions Does It Have?

MyLib.describe(:funding)
[%{
  name: :annualize,            # function name (atom)
  arity: 2,                    # max arity
  defaults: 1,                 # number of optional args — callable with 1 or 2 args
  description: "...",          # one-line description
  spec: "annualize(...) :: float()"  # typespec string (or nil)
}]

Scan this to find the function you need. Then drill in.

Level 3: Full Function Contract

MyLib.describe(:funding, :annualize)

This is where it pays off — everything you need to call the function correctly:

%{
  name: :annualize,
  arity: 2,
  defaults: 1,
  description: "Annualize a per-period funding rate to APR.",
  spec: "annualize(number(), pos_integer()) :: float()",
  params: %{
    rate: %{kind: :value, description: "Per-period funding rate as decimal",
            schema: %{"type" => "number"}},        # JSON Schema, derived from @spec
    period_hours: %{kind: :value, default: 8, description: "Hours per period",
                    schema: %{"type" => "integer"}}
  },
  opts: %{                     # keyword options (or nil if none)
    precision: %{type: :integer, default: 2, description: "Decimal places",
                 schema: %{"type" => "integer"}}   # derived from the opt's type:
  },
  returns: %{type: :float, description: "Annualized percentage rate"},
  returns_example: 10.95,      # a concrete value you can expect back
  errors: [invalid_period: "Period must be > 0"],
  composes_with: [:normalize_rate]  # what to call next
}

You now know the param order, which are optional, what types to pass, what comes back, what can go wrong, and what to chain with. You can call this function correctly without reading a single line of source code.

Reading the Contract

params — Positional arguments. Pass them in order. Check kind:

  • :value — you provide this directly (a number, string, config)
  • :exchange_data — must be fetched from an external source first (the param may include a source hint telling you where to get it)

opts — Keyword options. Pass as last argument: annualize(rate, period, precision: 4).

schema — A JSON Schema map present on most params/opts, giving the wire type for JSON/MCP callers. Derived automatically from the function's @spec (params) or the opt's type: (opts), or from an explicit schema: declaration. Absent only for types a typespec can't express (e.g. term(), tuples). Elixir callers can ignore it and use kind/type/default.

defaults — Number of trailing params with defaults. If arity: 3, defaults: 1, you can call with 2 or 3 args.

errors — Known error cases the function may return or raise; treat these as contract hints and follow the library's actual return conventions.

composes_with — Other functions in the same module that chain well with this one. Follow the chain to build pipelines without guessing.

returns_example — A concrete value showing what the output looks like. Use this to understand the shape before you call.

Combining Manual @doc with api()

api() generates both human-readable @doc text and machine-readable @doc hints: metadata. These live in separate slots in Elixir's compiled BEAM docs (element 4 and element 5 of the docs tuple) — they never collide.

This means you can write a manual @doc after api() to provide custom prose while keeping the structured metadata:

# api() writes hints metadata (slot 5) AND generated @doc text (slot 4)
api(:imbalance!, "Calculate orderbook bid/ask imbalance (raises on error).",
  params: [
    orderbook: [kind: :exchange_data, description: "Orderbook data"],
    depth: [kind: :value, default: 10, description: "Depth levels"]
  ],
  returns: %{type: :float, description: "Imbalance ratio"}
)

# Manual @doc AFTER api() — overwrites only slot 4 (prose), hints in slot 5 survive
@doc "Bang variant of `imbalance/2`. Returns the float directly or raises on error."
@spec imbalance!(map(), pos_integer()) :: float()
def imbalance!(orderbook, depth \\ 10), do: ...

Result: the function gets both the rich human-friendly @doc text AND the full machine-readable hints contract. Best of both worlds.

Important: The manual @doc must come after api() — Elixir uses last-wins for @doc text. If placed before, api()'s generated text overwrites it.

Alternative Entry Points

Direct Module Introspection

When you know the exact module, skip the top-level and go direct:

MyLib.Funding.__api__()
# => [%{name: :annualize, arity: 2, defaults: 1, spec: "...", hints: %{...}}, ...]

MyLib.Funding.__api__(:annualize)
# => %{name: :annualize, arity: 2, defaults: 1, spec: "...", hints: %{...}}

The hints map has the same fields as Level 3 (params, opts, returns, returns_example, errors, composes_with, description).

__api__/0 is runtime-enriched; the BEAM doc chunk is not. __api__/0 fills hints.params.<name>.schema / hints.opts.<name>.schema from the function's @spec and declared type: at runtime. The compile-time doc chunk (Code.fetch_docs/1meta[:hints]) is the raw declared surface and carries no spec-derived schemas, so the two diverge on :schema. This is intentional. If you cross-check the two surfaces for equality (e.g. to detect api() misattachment), normalize both with Descripex.normalize_for_doc_compare/1 first — it strips every :schema key so the comparison doesn't false-positive on the injected schema:

Descripex.normalize_for_doc_compare(MyLib.Funding.__api__(:annualize).hints) ==
  Descripex.normalize_for_doc_compare(meta_hints)

Get the Module List

MyLib.__descripex_modules__()
# => [MyLib.Funding, MyLib.Risk]

Without a Top-Level Discoverable

Use Descripex.Describe directly — same three levels, but you pass the module list:

Descripex.Describe.describe([MyLib.Funding, MyLib.Risk])
Descripex.Describe.describe([MyLib.Funding, MyLib.Risk], :funding)
Descripex.Describe.describe([MyLib.Funding, MyLib.Risk], :funding, :annualize)

Manifest (Batch/Offline)

Grab the entire library's API as a JSON-serializable map:

Descripex.Manifest.build([MyLib.Funding, MyLib.Risk])
%{
  version: "1.0",
  generated_at: "2025-01-01T00:00:00Z",
  modules: [%{
    module: "MyLib.Funding",     # strings in manifest (JSON-friendly)
    namespace: "/funding",
    description: "...",
    functions: [%{
      name: "annualize",        # strings (not atoms)
      arity: 2,
      defaults: 1,
      signature: "annualize(rate, period_hours)",
      description: "...",
      spec: "annualize(number(), pos_integer()) :: float()",
      hints: %{...}             # same shape as __api__ hints
    }]
  }]
}

Note: Manifest uses strings for module/function names. __api__ and describe use atoms.

For an offline / batch export to a file (CI, agent toolchains), use the Mix task instead of calling Manifest.build/1 yourself:

mix descripex.manifest MyLib.Funding MyLib.Risk   # writes api_manifest.json
mix descripex.manifest --app my_app               # auto-discover annotated modules in an app
mix descripex.manifest --pretty -o tools.json MyLib.Funding

MCP Tool Definitions

If you host the library behind the Model Context Protocol, turn its annotated modules straight into MCP tool definitions — no hand-written schemas:

Descripex.MCP.tools([MyLib.Funding, MyLib.Risk])
# => [%{
#   name: "funding__annualize",          # "<short_module>__<function>"
#   description: "Annualize a per-period funding rate to APR.",
#   inputSchema: %{type: "object", properties: %{...}, required: [...]}
# }, ...]

inputSchema is a JSON Schema assembled from the function's params and opts (the same schemas you see at Level 3). Pass name_style: :full for fully-qualified tool names (my_lib_funding__annualize). Functions without api() annotations are skipped.

Quick Reference

Want to...Call
List all modulesMyLib.describe()
List functions in a moduleMyLib.describe(:funding)
Get full function contractMyLib.describe(:funding, :annualize)
Get module listMyLib.__descripex_modules__()
Introspect one module directlyMyLib.Funding.__api__()
Introspect one function directlyMyLib.Funding.__api__(:annualize)
Get everything as JSON-ready mapDescripex.Manifest.build(modules)
Export manifest to diskmix descripex.manifest MyLib.Funding
Get MCP tool definitionsDescripex.MCP.tools(modules)
Detect descripex supportfunction_exported?(Mod, :__api__, 0)