Four things that share one source of truth, for the Elvanto church management API:
| Package | What it is | Install |
|---|---|---|
@criticalcodes/elvanto |
Typed TypeScript/JavaScript client | npm i @criticalcodes/elvanto |
@criticalcodes/elvanto-cli |
Command-line interface | npm install -g @criticalcodes/elvanto-cli |
@criticalcodes/elvanto-mcp |
MCP server, for LLM tools | npx @criticalcodes/elvanto-mcp |
@criticalcodes/elvanto-agent |
Agent toolkit for Flue | npm i @criticalcodes/elvanto-agent |
This version covers API key authentication and read-only endpoints — all 25 of them. OAuth and mutations are designed for but not implemented; see Roadmap.
Unofficial. Not affiliated with or endorsed by Elvanto.
import { createClient } from '@criticalcodes/elvanto'
const elvanto = createClient({ auth: { apiKey: process.env.ELVANTO_API_KEY! } })
const people = await elvanto.people.getAll({ page_size: 100, fields: ['birthday'] })
console.log(people.total, people.items[0]?.firstname)$ elvanto services get-all --fields songs --all
$ elvanto people search --search lastname=Smith -o jsonEvery endpoint is declared once, in
packages/elvanto/src/registry.ts: its path,
its zod parameter schema, its response schema, and its documentation link. The
SDK methods, the CLI commands and the MCP tools are all derived from it, so a
parameter cannot exist on one surface and be missing from another. The agent
toolkit generates its tools from the same registry too — same names, same
descriptions, same page and response caps — so a renamed endpoint is a compile
error rather than a runtime surprise.
Names are derived mechanically, so each surface reads idiomatically rather than leaking Elvanto's camelCase paths:
| Surface | Convention | Example |
|---|---|---|
| TypeScript | camelCase | client.peopleFlows.steps.getAll() |
| CLI | kebab-case | elvanto people-flows steps get-all |
| MCP | snake_case | elvanto_people_flows_steps_get_all |
Adding an endpoint means adding one registry entry and one binding line in
client.ts. The CLI, the MCP server and the agent's endpoint tools pick it up with
no further work.
Elvanto's JSON is a mechanical translation of an XML document, which shows. This library reshapes it on the way out, consistently and predictably:
- Singular-key collection wrappers are flattened.
{ locations: { location: [...] } }becomes{ locations: [...] }. An empty collection (Elvanto sends"") becomes[]. An absent one staysundefined— so you can tell "none" from "not requested". - Single records are unwrapped, whether Elvanto sent a one-element array
(
person: [{...}], most endpoints) or a bare object (transaction: {...}, the financial endpoints). - Pagination is lifted into
{ items, page, perPage, onThisPage, total, hasMore }. - Documented booleans become booleans.
1/0,"Yes"/"No","true"/"false"and""all normalize. Elvanto's numeric states — like a service'sstatus— are deliberately left alone, because1there means "published", not "true". - Inconsistently quoted numbers become numbers. The same field arrives as
125from one endpoint and"360.00"from another. - Everything else is verbatim, and unknown fields are always preserved — accounts have custom fields, and Elvanto ships changes ahead of its docs.
Dates stay as Elvanto's strings, because converting them would lose fidelity.
Use parseElvantoDate(), which knows that Elvanto's "2026-02-24 11:56:22" is
UTC despite carrying no zone marker (new Date() would read it as local time).
Elvanto publishes no OpenAPI or JSON Schema. Every response schema here is derived from the examples in its documentation, which means the schemas can be wrong in two directions: fields that exist but aren't documented, and documented fields that behave differently in practice.
So validation is configurable, and strict by default:
| Mode | Behaviour | Use it when |
|---|---|---|
throw (default) |
Raises ElvantoResponseValidationError on a mismatch. The raw payload is on error.data. |
Tests and CI — you want drift to be loud. |
warn |
Returns the data anyway and reports through onWarning. |
Production, where a new Elvanto field must not break a working call. |
off |
Skips response validation. Structural normalization still applies. | Maximum throughput, or when you've decided to trust it. |
createClient({ validate: 'warn', onWarning: (w) => log.warn(w.message) })Every surface exposes the opt-out: --validate warn on the CLI,
ELVANTO_VALIDATE=warn for the MCP server and the SDK.
Request parameters are always validated strictly, regardless of this setting — those are well documented, so a bad parameter is your bug, not Elvanto's.
Two things worth knowing:
- Under
warn, records that validate keep their normalization and only the offending record comes back raw. One unexpected field on one person doesn't cost the rest of the page its booleans. - Under
warnandoff, the static types are a claim rather than a guarantee — a result typedPerson[]may contain an unvalidated item. That's the trade those modes exist to make; runthrowin tests so the claim is checked somewhere.
Errors can carry member data by design: ElvantoApiError.body and
ElvantoResponseValidationError.data hold the raw response, because a mismatch
can't be diagnosed without it. Log error.message, not the whole object.
Off by default. debug: true (or ELVANTO_DEBUG=1) logs requests, HTTP status,
durations, retries and result counts to stderr — never stdout, so it can't
corrupt piped CLI output or the MCP stdio channel.
$ elvanto people get-all --debug
[elvanto] request people/getAll — POST url=… bytes=16 params=page_size
[elvanto] response people/getAll — HTTP 200 durationMs=142 attempt=1 generatedIn=0.021
[elvanto] result people.getAll — page returned=25 total=668 page=1 hasMore=trueBecause this library handles member records and giving data, the log stream is treated as somewhere that data must not reach:
- Credentials are never logged, at any level, in any encoding.
- Returned records are never logged — only counts, statuses and timings. When
you need a payload to diagnose a mismatch, use
validate: 'warn'and read it from the warning. - Parameter values are only logged with
debug: 'verbose'; names alone are logged otherwise. Even in verbose,searchvalues are redacted to a count, since those are the terms themselves.
Pass logger to route events into your own logging stack instead of stderr.
Because the schemas come from documentation examples, the honest way to check them is to call the API. The smoke test sweeps every read-only endpoint, chains IDs from one call into the next, and reports what it finds:
Pass the key per invocation. Nothing here reads a .env, deliberately: an
Elvanto API key grants read access to every member record and every giving
record in the account, and this script is run occasionally — not often enough to
justify leaving that on disk in plaintext. Pulling it from a secret manager keeps
it out of both the filesystem and your shell history:
$ ELVANTO_API_KEY=$(op read "op://Private/Elvanto/api key") pnpm smoke
$ ELVANTO_API_KEY=$(security find-generic-password -s elvanto -w) pnpm smoke
$ ELVANTO_API_KEY=your-key pnpm smoke # fine too; lands in shell history
ok people.getAll (+1 undocumented)
ok people.search
skip people.currentUser
…
24 ok, 1 skipped, 0 API errors, 0 failed
Fields Elvanto returned that our schemas do not declare:
people.getAll: brand_new_field_from_elvantoIt reports fields Elvanto returned that we don't declare (the docs were
incomplete) and fields we declare that never appeared (we may be wrong).
Values are redacted by default — this reads real member data. Add
--include-data for samples, --financial to include giving records (excluded
by default), and --json report.json for the full report.
Every endpoint's parameters and response shape is checked against Elvanto's
published example — that's the floor. A live sweep has additionally confirmed 14
of the 25 against real data, and each endpoint records which in its registry entry
(verified: 'docs' | 'live'). elvanto endpoints marks the difference, and the
MCP tool descriptions carry a caveat for the docs-only ones.
The distinction is worth keeping because a live sweep has already contradicted the
documentation twice: school_grade is an object rather than the name it's
documented as, and family is not the person collection its name implies. So a
docs-only endpoint is probably right, but nothing has tested it.
Still docs-only, and why:
| Endpoints | Why |
|---|---|
songs.* (5) |
No songs in the account swept, so nothing downstream was reachable |
financial.* (3) |
No chart of accounts and no transactions. Also where the published examples disagree with each other most |
peopleFlows.steps.people |
The endpoint answered, but no step had members to shape-check |
people.currentUser |
Requires OAuth, which isn't implemented yet |
Within services, the envelope, service_times and the whole volunteers tree
were exercised for real; plans, songs, files and notes came back empty
everywhere, so those four remain documentation-only.
pnpm install
pnpm build # all four packages
pnpm typecheck # includes compile-time type assertions
pnpm test # 381 tests, no network
pnpm test:coverage
pnpm smoke # live sweep, needs a real API keyThe agent package additionally builds a deployable server, which is a separate
step from the publishable toolkit and writes to dist-app/ rather than dist/:
pnpm --filter @criticalcodes/elvanto-agent build:app # Cloudflare Worker
FLUE_TARGET=node pnpm --filter @criticalcodes/elvanto-agent build:app # Node serverDeveloping here needs Node 22.13+, because pnpm 11 does. The published packages
support Node 20+, which is a separate claim and tested separately: CI packs the
SDK and runs scripts/runtime-check.mjs against the tarball on Node 20, 22 and 24,
installed with plain npm. Narrowing engines to match the toolchain would have been
easier and false.
pnpm check:secrets fails on credential-shaped strings and on sweep output in
tracked files, and runs first in CI. It also works as a pre-commit hook:
echo 'node scripts/check-no-secrets.mjs --staged' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitSee CONTRIBUTING.md for the conventions that aren't obvious from the code, and SECURITY.md for what's worth reporting.
Tests never touch the network: the SDK takes an injected fetch, and the CLI and
MCP tests run against a local stub server and an in-memory MCP transport
respectively, so they exercise real sockets and the real protocol.
Both pnpm test and pnpm typecheck resolve @criticalcodes/elvanto to the
SDK's source, not its build output — via aliases in vitest.config.ts and
paths in the consuming tsconfigs. Without that, a change to the SDK stays
invisible to most of the suite until someone runs pnpm build, and the
tests pass against the previous build. @criticalcodes/elvanto-mcp is aliased the
same way, since the agent package imports it.
packages/elvanto-agent/tsconfig.json is the one package config that does not
extend tsconfig.base.json: Flue's agent and app modules import each other with
explicit .ts extensions, which needs bundler resolution. The base's strictness
flags are repeated there rather than dropped.
Three routes now, in increasing order of how much is done for you.
The agent toolkit, if you use Flue.
@criticalcodes/elvanto-agent ships six tools that each answer a whole question —
find_person, roster, next_serving, service_brief, song_history,
list_custom_fields — plus all 25 raw endpoints as native tools, a
useElvantoBase() hook that mounts them, and a one-binary runner that gives you a
terminal chat, an HTTP server and a web chat UI from the same executable. Each
purpose-built tool collapses a multi-call workflow into one small result, which
matters because the raw endpoints are faithful to Elvanto and Elvanto's shapes are
large.
The MCP server, for a host that is somebody else's — Claude Desktop, a remote
connector, another framework. (Not needed to give your own Flue agent the raw
endpoints; the toolkit mounts those in-process.) @criticalcodes/elvanto-mcp works today with any MCP-capable host — 25
read-only tools, schemas generated from the registry, a 25-record page default and
a response cap so a large account can't flood a context window. It speaks stdio for
desktop clients and streamable HTTP for hosts that only take a URL, which includes
most agent frameworks.
The SDK directly, for an agent with a specific job. Everything needed to generate tools is public, so a framework can enumerate the registry rather than hand-writing wrappers:
import { endpointIds, getEndpoint, paramsJsonSchema, createClient } from '@criticalcodes/elvanto'
const client = createClient({ validate: 'warn' })
const tools = endpointIds.map((id) => {
const endpoint = getEndpoint(id)
return {
name: id,
description: endpoint.summary,
// JSON Schema, or reach `endpoint.params` for the zod schema directly.
inputSchema: paramsJsonSchema(endpoint),
run: (args) => client.call(id, args),
}
})Four things worth deciding up front. The agent toolkit has made each of these decisions already, so they double as a description of what it does:
- Exposing fewer tools beats exposing all 25. An agent that only needs rosters does not need the giving endpoints, and the narrowest surface is the easiest to reason about. The toolkit's default allowlist omits every financial endpoint.
validate: 'warn'is usually right for an agent, so an undocumented Elvanto field degrades the response instead of failing the session. Keepthrowin tests.- Cap what reaches the context. The MCP server does this for you; direct SDK
use does not —
fetchAllon a large account will happily return 50,000 records. UsepaginatewithmaxRecords, or a smallpage_size. Say when you truncate: a silently shortened list reads as a complete answer, and a model will present it as one. - Build the client lazily. A framework that constructs it while composing the agent turns a missing API key into an internal error before the agent exists, instead of a message the model can relay.
The toolkit is deliberately generic — it takes configuration and has no policy of
its own. Anything specific to an account (which custom fields carry your
safe-ministry credentials, renewal windows, notice wording, who gets chased) belongs
in a repository you control, not in a public package. useElvantoBase() is a
composition hook, so your agent mounts the shared tools and then its own:
useElvantoBase()
for (const tool of myCredentialTools(profile)) useTool(tool)Deliberately not in this version:
- OAuth 2. The transport already supports bearer tokens and a
getAccessTokenhook for refresh, so the remaining work is the authorization code flow and token storage.people.currentUseris registered and will start working the moment a token is supplied. - Mutations.
create,edit,remove,addPersonand the rest. The registry has nomethodfield yet because every endpoint here is a POST that reads; adding writes should also add an explicit opt-in, so an MCP server cannot be handed the ability to delete a person by accident. - Anything outbound from the agent. The toolkit reads and reports; it sends no email or SMS. Notifying people is a mutation of the world rather than of Elvanto, and it should be an explicit, separately-authorised step rather than something a model can decide to do.
song_historyagainst real data. It reads thesongssub-structure of a service, which came back empty on every service in the live sweep — so its shape still rests on Elvanto's documentation. An empty result may mean the shape is wrong rather than that nothing was sung; the tool says so rather than asserting.
Things worth knowing, all of which this library handles for you:
- Every endpoint is a
POST, including the reads. - API keys authenticate as HTTP Basic with the key as the username and an ignored password.
- Failures sometimes arrive as HTTP 200 with
{"status":"fail"}in the body, and the meaningful code is inerror.coderather than the status line. - A 404 means both "that ID doesn't exist" and "nothing matched your filters".
client.paginate()treats the latter as an empty result rather than an error. page_sizemust be between 10 and 1000. Elvanto's default is 1000; the MCP server defaults to 25 instead, so a large account can't flood a model's context.- Optional fields are only returned when named in a request's
fieldsparameter. Usepeople.customFields.getAll()to discover thecustom_<uuid>keys your account accepts.
MIT