Sources and importers
How to bring another service's data into a site without code: sources.yaml for cached, allowlisted upstream JSON read by templates and endpoints (with caching, failure modes and the circuit breaker), and importers.yaml for scheduled feed imports that create and update documents by external id, with every option, default and limit.
Two kinds of inbound integration need only configuration:
- A source is another service’s JSON that a page or an endpoint reads at request time: a weather forecast, a tide table, a booking system’s availability. The engine fetches it, caches it and protects the page from the service failing.
- An importer fetches a JSON feed on a schedule and turns each item into a document of one of your types, matched by the item’s id in the feed, so a second run updates what the first created. It never publishes.
Both fetch only the addresses written in configuration. A request can never make the engine fetch an address it supplies, and a redirect is followed only to the same scheme and host.
engine-cms behaves the same.
Sources
# config/sites/<site>/sources.yaml
sources:
weather:
url: https://api.example.org/forecast?lat=52.06&lon=-1.34
headers:
Authorization: "Bearer ${WEATHER_TOKEN}"
ttl: 10m
timeout: 3s
max_bytes: 256KB
on_error: stale_then_empty
circuit_breaker: { failures: 5, window: 1m, cooldown: 30s } Run engine-dmo check --fix to give it its stable id (src_...). Rename one with engine-dmo dev source rename <old> <new>, which also updates every endpoint step that names it.
| Key | Default | Limits | Meaning |
|---|---|---|---|
url | (required) | An absolute http or https URL, no credentials in it | The address fetched with GET and Accept: application/json. |
headers | none | Request headers. A header whose name contains auth, key, token, secret, password or cookie must be a ${VAR} interpolation, never a literal. | |
ttl | 5m | At most 24h | How long a fetched answer is reused. |
timeout | 5s | At most 30s | How long one fetch may take. |
max_bytes | 1MB | At most 10MB | The largest answer accepted; a larger one is a failure. |
on_error | stale_then_empty | stale_then_empty, stale_then_fail, empty, fail | What a failed fetch answers. See below. |
circuit_breaker.failures | 5 | 1 to 100 | Failures inside window that open the breaker. |
circuit_breaker.window | 1m | At most 1h | |
circuit_breaker.cooldown | 30s | At most 1h | While open, the source answers as on_error says without calling the service. |
The answer must be JSON with a 2xx status. Each engine process caches each source in memory for ttl, and fetches it once at a time: a burst of requests after the ttl lapses waits for the first fetch rather than each calling the service. The cache is not shared: each replica fetches for itself, and a restarted process starts empty, with no last good answer for the stale_ choices below to fall back on until its first successful fetch.
on_error | When the fetch fails or the breaker is open |
|---|---|
stale_then_empty | The last good answer if there is one, else empty. |
stale_then_fail | The last good answer if there is one, else a failure. |
empty | Empty. |
fail | A failure. |
Empty is nothing (null). A failure fails an endpoint’s source step with HTTP 502 (“This information is not available just now. Try again in a moment.”); in a template, source() is simply empty.
In a template
{% set forecast = source('weather') %}
{% if forecast %}
<p>{{ forecast.summary }}, {{ forecast.temperature }}°C</p>
{% endif %} source('name') returns the parsed JSON, or nothing when the source is not declared or cannot answer, so always handle the empty case. See Functions.
In an endpoint
# config/sites/<site>/endpoints.yaml
endpoints:
- key: forecast
path: /api/forecast.json
methods: [get]
access: public
steps:
- source: { name: weather, as: forecast }
respond:
json: { from: forecast, fields: [summary, temperature] }
cache: 5m The source step takes name (a source in this site’s sources.yaml) and as (the name later steps and respond read it by; the source’s name when left out). respond: json: exposes only the fields it lists. See Forms and submissions for endpoints.
Importers
# config/sites/<site>/importers.yaml
importers:
partner-events:
url: https://feeds.example.org/events.json
headers:
Authorization: "Bearer ${EVENTS_FEED_TOKEN}"
schedule: "0 4 * * *"
type: notice
locale: en-GB
items: data.events
external_id: id
map:
title: name
summary: description.short
startDate: dates.start
status: in_review
max_items: 500 Here notice is a type of the project’s with title, slug, summary (text) and startDate (date) fields. With a feed like this, each object under data.events becomes a notice document in en-GB, its title from name, its summary from description.short and its startDate from dates.start:
{ "data": { "events": [
{ "id": "ev-1042", "name": "Summer fair", "description": { "short": "Stalls, music and a dog show." }, "dates": { "start": "2026-07-18" } }
] } } Run engine-dmo check --fix to give it its id (imp_...). Its import records are keyed by that id, so a renamed importer (engine-dmo dev importer rename <old> <new>) keeps matching the documents it made.
| Key | Default | Limits | Meaning |
|---|---|---|---|
url | (required) | An absolute http or https URL, no credentials | The feed, fetched with GET. |
headers | none | Credentials only as ${VAR} | Request headers. |
schedule | (required) | A five-field cron expression, read in UTC | When it runs: "0 4 * * *" is 04:00 UTC daily. |
type | (required) | A type on this site | The type each item becomes. |
locale | The site’s default locale | A locale the site enables | The locale written. |
items | The body itself | A dotted path | Where the list of items is in the feed’s JSON. |
external_id | (required) | A dotted path into an item; the value at most 200 characters | The item’s id in the feed, which the import matches by. |
map | (required, at least one) | Field name to a dotted path into the item | The fields the import writes. A blocks, repeater, portable_text, asset or video field cannot be imported from a feed. |
status | in_review | draft or in_review | What an imported document is left as. An import never publishes. |
timeout | 30s | At most 5m | How long the fetch may take. |
max_bytes | 10MB | At most 50MB | The largest feed accepted. |
max_items | 1000 | 1 to 10,000 | The most items one run imports; the rest are reported and left. |
What a run does
For each item:
- It reads the external id. An item without one is skipped and reported.
- It reads each mapped field. When
slugis not mapped, the slug is made from the mappedtitle(oritem-<external id>when there is none). - If nothing mapped has changed since the last run, the item is unchanged.
- If the item was imported before, the document’s working revision in the locale is updated: the import owns the fields it maps and nothing else, so an editor’s work on any other field survives the next run. A document that was trashed or purged since is created again.
- Otherwise a document is created, owned by the site.
- With
status: in_review, the document is sent to review.
The values are validated as an editor’s save is; an item whose values fail is skipped with the reason. Each revision is recorded as written by the import (Imported by <key>). A run reports how many items were created, updated, unchanged and skipped, with up to 50 problems, in the job’s progress.
Running an importer now
Each importer is a scheduled task named importer.<site>.<key>:
engine-dmo schedule list --project .
engine-dmo schedule run importer.main.partner-events --now --project . A staff endpoint can also queue a run with a job step, which is the one job kind an endpoint may queue:
steps:
- job: { name: importer.run, with: { importer: partner-events } } A burst of runs of one importer collapses into one.
Webhooks
How Meridian tells other systems that something happened: webhooks.yaml with every option and limit, the content events and the endpoint webhook step, the request body and headers, verifying the HMAC signature, retries and backoff, the delivery log, and replaying a delivery.
Content MCP
How to connect an AI agent (Claude or any Model Context Protocol client) to a Meridian site: turning the endpoint on, OAuth sign-in and the consent choices, the mcp configuration block, registering clients, personal access tokens and engine-dmo mcp call for scripts, engine-dmo mcp over stdio for local development, the tools, resources and prompts, limits, and what to tell a data protection officer.