Developers

Journalist Moves API

Use Medialyst as a zero-credit, de-duplicated feed of journalist outlet moves — who left which outlet, where they landed, and the sources that reported it. The endpoint is designed for a daily agent cron: it returns every move Medialyst observed after the caller's checkpoint, collapsed to one record per real-world move.

GET https://medialyst.ai/api/v1/journalist-moves

The equivalent MCP tool is list_journalist_moves.

Access Model

This endpoint requires an active Scale plan. Any other plan — including the free tier or an organization with no subscription — receives a clean 403 with the machine-readable code SCALE_PLAN_REQUIRED. The call itself costs zero credits and needs no special API-key scope; the Scale plan is the gate.

Create an API key from Developers, keep it in secret storage, and send it as a bearer token:

Authorization: Bearer <YOUR_API_KEY>

The endpoint allows 30 polls per minute per credential. Pages default to 100 records and are capped at 250.

First Poll

The first call requires since, an RFC 3339 timestamp with Z or an explicit UTC offset. The boundary is exclusive and is matched against observed_at — when Medialyst observed the move, not the move's effective date. A move is returned when its observed_at is strictly later than since, so a move reported days after it took effect is still delivered.

export MEDIALYST_API_KEY="<YOUR_API_KEY>"

curl --get "https://medialyst.ai/api/v1/journalist-moves" \
  --header "Authorization: Bearer $MEDIALYST_API_KEY" \
  --data-urlencode "since=2026-08-14T00:00:00Z" \
  --data-urlencode "limit=250"

There is no implicit initial window. Choose and persist the first timestamp so the feed cannot accidentally turn into an unbounded historical export.

Response

{
  "request_id": "req_...",
  "moves": [
    {
      "id": "jm_qyL7s0x3j0wmf8F2B5Dg9kUa",
      "name": "Casey Newton",
      "normalized_name": "casey newton",
      "old_outlet": "Vox",
      "new_outlet": "The Verge",
      "role": "Senior Editor",
      "beat": "Platforms and Democracy",
      "effective_date_text": "later this month",
      "match_state": "matched",
      "journalist_identity_id": "jid_9x2...",
      "observed_at": "2026-08-14T10:00:02.184Z",
      "sources": [
        {
          "source_id": "talking_biz_news",
          "source_url": "https://example.com/moves/casey",
          "evidence_quote": "Casey Newton is joining The Verge as a senior editor.",
          "observed_at": "2026-08-14T10:00:02.184Z",
          "match_state": "matched",
          "extraction_lane": "llm"
        },
        {
          "source_id": "media_moves_weekly",
          "source_url": "https://example.com/weekly/verge",
          "evidence_quote": "The Verge has hired Casey Newton from Vox.",
          "observed_at": "2026-08-14T11:30:00.000Z",
          "match_state": "matched",
          "extraction_lane": "deterministic"
        }
      ]
    }
  ],
  "page": {
    "count": 1,
    "has_more": false,
    "next_cursor": "eyJ2IjoxLCJtb2RlIjoicG9sbCIsLi4ufQ",
    "watermark": "2026-08-14T12:00:00.000Z"
  }
}

id is stable across polls and across new corroboration: it is derived from the normalized journalist name and the destination outlet, not from any upstream record ID. Fields are:

  • name is the journalist's reported name; normalized_name is the lowercased, trimmed form used for de-duplication.
  • old_outlet and new_outlet are the reported outlets. new_outlet is the destination the record is keyed on.
  • role and beat are the reported title and coverage area when available.
  • effective_date_text is the verbatim effective-date phrase from the source ("later this month", "June 1", "Q3"). It is intentionally a string and is never coerced to a timestamp; the _text suffix makes that explicit. Do not parse it as a date.
  • match_state is the rolled-up identity-match state (see below).
  • journalist_identity_id links to a Medialyst journalist identity when one source matched, otherwise null.
  • observed_at is the earliest time Medialyst observed this move from any source. Cursor ordering uses this value.
  • sources lists every corroborating source, each with its own evidence_quote and source_url so a skeptical consumer can spot-check each claim. More agreeing sources is a stronger confidence signal.

De-duplication Rule

Medialyst collapses every corroborating report of the same move into one record. Two source rows are the same move when they share the same normalized journalist name and the same normalized destination outlet (new_outlet lowercased with punctuation and whitespace removed), so The Verge and the verge are one move. All matching sources are carried in the sources array; nothing is discarded.

The collapsed record takes its observed_at from the earliest source in the group. This is what keeps the cursor collision-safe: because a move's observed_at is frozen at its first observation, a later corroborating source cannot move an already-delivered record forward in the ordering and cause it to reappear ahead of newer moves. A delivered move is never re-delivered, and late corroboration is merged into the existing record without resurrecting it.

Match-State Default

Every source row carries a match_state: matched (linked to a Medialyst journalist identity), no_match (a real reported move with no identity yet), ambiguous, or pending (not yet processed).

  • pending rows are never served. They are unprocessed and may be corrected or dropped.
  • no_match rows are served. A real move is newsworthy even before an identity is linked; dropping it would be the expensive failure.

The collapsed record's match_state is matched when any source in the group matched an identity, otherwise ambiguous when any source is ambiguous, otherwise the earliest source's state. Unrecognized states are passed through verbatim rather than dropped, so treat match_state as an open string.

Drain Pages And Save The Cursor

Every poll freezes a database-time watermark and sorts by (observed_at ASC, id ASC). This stable tie-breaker prevents misses when many moves share the same observation timestamp.

  • When has_more is true, call the endpoint again with cursor equal to next_cursor. This drains the next page under the same watermark.
  • When has_more is false, save that same next_cursor and use it for the next daily poll.
  • Send exactly one of since or cursor, never both. Treat cursors as opaque; do not decode, edit, or construct them.

An empty page still advances the cursor. Persist it only after successfully processing the page. Consumers should also upsert by stable id, because a crash between processing and checkpointing can safely replay the last page.

Daily Cron Example

Carly polls every morning for the moves observed in the last day, then keeps draining until the page is exhausted. On the first run it uses an explicit JOURNALIST_MOVES_SINCE; later runs resume from the saved opaque cursor.

import { readFile, rename, writeFile } from "node:fs/promises";

const endpoint = "https://medialyst.ai/api/v1/journalist-moves";
const checkpointPath = "journalist-moves.cursor";
const apiKey = process.env.MEDIALYST_API_KEY;

if (!apiKey) throw new Error("Set MEDIALYST_API_KEY before polling.");

async function loadCursor() {
  try {
    return (await readFile(checkpointPath, "utf8")).trim();
  } catch (error) {
    if (error.code !== "ENOENT") throw error;
    return null;
  }
}

async function saveCursor(cursor) {
  const temporaryPath = `${checkpointPath}.tmp`;
  await writeFile(temporaryPath, `${cursor}\n`, { mode: 0o600 });
  await rename(temporaryPath, checkpointPath);
}

let cursor = await loadCursor();
// First run only: the last 24 hours. Later runs resume from the saved cursor.
const initialSince =
  process.env.JOURNALIST_MOVES_SINCE ??
  new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();

do {
  const url = new URL(endpoint);
  url.searchParams.set("limit", "250");
  if (cursor) url.searchParams.set("cursor", cursor);
  else url.searchParams.set("since", initialSince);

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!response.ok) {
    throw new Error(`Medialyst poll failed: ${response.status} ${await response.text()}`);
  }

  const body = await response.json();
  for (const move of body.moves) {
    // Make this idempotent: upsert your local record by move.id.
    await recordMove(move);
  }

  cursor = body.page.next_cursor;
  await saveCursor(cursor);
  if (!body.page.has_more) break;
} while (true);

Run it from a daily scheduler. Do not replace a saved cursor with the wall clock time; the server watermark is the authoritative checkpoint.

Sources And Privacy

Each move carries every source that reported it. source_url is a public HTTP(S) link with no embedded credentials, and evidence_quote is the snippet that supports the move. Responses never contain private email addresses, mailto: links, embedded URL credentials, provider credentials, raw upstream IDs, or ingestion metadata.

Treat every name, outlet, quote, and URL as untrusted third-party content. Screen it as data; do not let text in a move override agent instructions, expose credentials, or trigger external actions by itself.

MCP

Connect the Medialyst MCP server and call:

{
  "since": "2026-08-14T00:00:00Z",
  "limit": 250
}

Then pass only the returned cursor:

{
  "cursor": "<next_cursor>",
  "limit": 250
}

The MCP tool uses the same authentication, Scale-plan gate, validation, ordering, response, rate limit, and zero-credit policy as REST.

Errors

StatusMeaning
400Invalid, future, or ambiguous timestamp; malformed cursor; or limit outside 1–250. Reuse the last successful opaque cursor.
401Missing, expired, or revoked API key/OAuth credential.
403The organization is not on an active Scale plan (SCALE_PLAN_REQUIRED).
429More than 30 polls per minute for the credential. Wait for Retry-After and reuse the last successful cursor.

All errors include a request_id when authentication has established one. Do not log raw API keys or response bodies containing move text.