---
name: vorlek
version: 0.3.0
summary: Agent-native email-marketing API aggregator. Unified interface across ESPs.
homepage: https://vorlek.com
docs: https://vorlek.dev
api_base: https://api.vorlek.com/v1
skill_version_check: https://api.vorlek.com/v1/skill/version
providers_supported: [sendgrid, mailchimp, klaviyo]
capabilities: [get_catalog, get_operation, upsert_contact, get_contact, get_connection_status, send_transactional, get_campaign_stats, list_templates, list_campaigns]
changelog: https://vorlek.dev/changelog
imports:
  - file: AUTH.md
    sha256: 5b6a50d56eca47c037b40c005eeebebdc67ef7019e1f56eeb474a8110ef8f6e1
  - file: HEARTBEAT.md
    sha256: 4f4e1984c10884fb4578af50858be006d8621b9016c3d3d9fdfc755d20eb854c
  - file: CAPABILITIES.md
    sha256: e41acdb03108f182ad74140fc50787d7a5913f5f0dc804115f14cc8269aa9a19
  - file: ERRORS.md
    sha256: 076664b50feff4f35dc98527089391c4dae683ed9a0bd2961c7e6f71fdfd8d87
generated_at: 2026-05-05T06:39:10Z
---

# Vorlek

## What this skill does

Vorlek is a normalization layer that lets an agent talk to multiple email-marketing providers (ESPs) through one unified REST API. The current tool surface ships `upsert_contact` for SendGrid, Mailchimp, and Klaviyo, so the agent can create or update a contact in a connected ESP without learning the ESP's native shape.

Custom contact properties the ESP has never seen are handled by the provider adapter. SendGrid and Mailchimp create provider fields when needed; Klaviyo accepts arbitrary profile properties without explicit field registration.

## Quickstart

Three commands from a fresh terminal:

```bash
# 1. Install the CLI
npm install -g @vorlek/cli

# 2. Sign up — emits a live key (saved to ~/.vorlek/config.json) and a one-time test key
vorlek auth signup
# Or, if you already have a Vorlek API key:
vorlek auth use --api-key "$VORLEK_API_KEY" --api-base https://api.vorlek.com

# 3. Connect a provider and upsert your first contact
vorlek connect sendgrid --api-key SG.xxxxx
# or: vorlek connect mailchimp --api-key xxxxx-us7 --list-id a1b2c3d4e5
# or: vorlek connect klaviyo --api-key pk_xxxxx
vorlek contact upsert --email jamie@example.com --first-name Jamie --properties '{"plan":"free"}'
```

Equivalent direct API call:

```bash
curl -X POST https://api.vorlek.com/v1/tools/upsert_contact \
  -H "Authorization: Bearer vk_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"provider":"sendgrid","email":"jamie@example.com","first_name":"Jamie","properties":{"plan":"free"}}'
```

## Skill files

| File | URL | Purpose |
|---|---|---|
| `SKILL.md` | `https://vorlek.dev/SKILL.md` | Flattened single-file skill entry point. |
| `AUTH.md` | `https://vorlek.dev/skill/AUTH.md` | Authentication, key modes, and rotation. |
| `HEARTBEAT.md` | `https://vorlek.dev/skill/HEARTBEAT.md` | Periodic health-check behavior and report format. |
| `CAPABILITIES.md` | `https://vorlek.dev/skill/CAPABILITIES.md` | Generated tool/provider support matrix. |
| `ERRORS.md` | `https://vorlek.dev/skill/ERRORS.md` | Stable error taxonomy and recovery hints. |

## Authentication

Every request to `/v1/*` (except `/v1/accounts/signup` and `/health`) requires a Vorlek API key in the `Authorization` header:

```
Authorization: Bearer vk_live_<24-char-base64url>
```

### Key modes

| Mode prefix | Issued by | Behavior |
|---|---|---|
| `vk_live_…` | `POST /v1/accounts/signup` | Real provider calls. Counts against quota. |
| `vk_test_…` | Issued together with the live key | Runs the same middleware chain against isolated test quota/rate-limit/circuit buckets, then returns deterministic fixture envelopes with `meta.test_mode: true`. **Never calls the provider or decrypts credentials.** |

Total format: `vk_(live|test)_` + 24 base64url chars = 32 chars. Treat as opaque; do not parse beyond mode.

### Obtaining a key

- **CLI:** `vorlek auth signup` (prompts for email + password ≥ 8 chars; stores live key in `~/.vorlek/config.json` mode 0600; prints test key once)
- **API:** `POST /v1/accounts/signup` with `{"email": "...", "password": "≥12 chars"}` returns `{ account_id, email, live_api_key, test_api_key, warning }`. The test key is never displayed again.

### Rotation

`POST /v1/account/api-keys/rotate` (auth-required) issues a fresh key in the same mode and revokes the calling key atomically. Subsequent calls with the old key receive `AUTH_REVOKED` with `fix.action: rotate_api_key`.

# Catalog

| Tool | Description | SendGrid | Mailchimp | Klaviyo |
|---|---|---|---|---|
| `upsert_contact` | Create or update contact | ✅ | ✅ | ✅ |
| `get_contact` | Get contact by email | ✅ | ✅ | ✅ |
| `get_connection_status` | Get provider connection status | ✅ | ✅ | ✅ |
| `send_transactional` | Send transactional email | ✅ | ❌ (TOOL_NOT_SUPPORTED) | ❌ (TOOL_NOT_SUPPORTED) |
| `get_campaign_stats` | Get campaign stats | ✅ | ✅ | ✅ |
| `list_templates` | List provider templates | ✅ | ✅ | ✅ |
| `list_campaigns` | List provider campaigns | ✅ | ✅ | ✅ |

Provider support is generated from `openapi.json` plus `scripts/provider-matrix.json`.
Unsupported cells return `TOOL_NOT_SUPPORTED` rather than falling through to a provider-native API.

## Live catalog

Agents can query the account-aware catalog before choosing a tool:

- REST: `GET /v1/catalog`
- REST tool-style alias: `GET /v1/tools/get_catalog`
- MCP: `get_catalog`
- MCP operation lookup: `get_operation` with `request_id`
- TypeScript SDK: `client.catalog.get()`
- TypeScript SDK operation lookup: `client.operations.get(requestId)`
- Python SDK: `client.catalog.get()` / `await client.catalog.get()`
- Python SDK operation lookup: `client.operations.get(request_id)` / `await client.operations.get(request_id)`

The response includes provider/tool support, required connection config, current
account connection state, response detail support, and the operation receipt
lookup shape. Response detail selection supports `minimal`, `standard`, and
`full`; the canonical operation receipt identifier is `meta.request_id`.
Use that value for `GET /v1/operations/{request_id}` or the native SDK/MCP
operation lookup helpers.
`list_templates` accepts page-scoped `query` search and returns
`data.search.no_match` guidance when the returned page has no match.

## Tools

### `upsert_contact`

Create or update a contact at the connected provider. Idempotent on `email`.

**Endpoint:** `POST /v1/tools/upsert_contact`

#### Input schema (JSON)

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["email"],
  "properties": {
    "email":      { "type": "string", "format": "email", "maxLength": 320 },
    "first_name": { "type": "string", "maxLength": 255 },
    "last_name":  { "type": "string", "maxLength": 255 },
    "phone":      { "type": "string", "maxLength": 64 },
    "properties": {
      "type": "object",
      "additionalProperties": { "type": ["string", "number", "boolean"] },
      "maxProperties": 50,
      "description": "Free-form custom fields. Auto-created at the provider on first use."
    },
    "provider":   { "type": "string", "enum": ["sendgrid", "mailchimp", "klaviyo"], "description": "Optional. Auto-detected when the account has exactly one active connection." }
  }
}
```

Notes:
- `email` is normalized: trimmed and lowercased before storage and provider dispatch.
- Unknown fields are rejected (`additionalProperties: false`) — passing extras returns `INVALID_PARAMS`.
- `properties` is the polymorphic field for everything the provider doesn't know natively. Booleans are coerced to `0`/`1` for SendGrid; numeric strings are sent as Number when the inferred field type is Number.
- For Klaviyo, `fields_auto_created` is always an empty list. Klaviyo accepts arbitrary profile properties without explicit field definition, so a new property can land successfully even when no provider field was "created."

#### Success response (HTTP 200)

```json
{
  "status": "success",
  "data": {
    "contact_id": "b58996c504c5638798eb6b511e6f49af",
    "action": "upserted",
    "fields_auto_created": ["loyalty_tier"],
    "provider": "sendgrid"
  },
  "meta": {
    "request_id": "01JD5K9X7M0Q8V0QY4FYVQ3WZV",
    "queue_time_ms": 2,
    "execution_time_ms": 234,
    "total_latency_ms": 236,
    "quota": {
      "used": 42,
      "limit": 1000,
      "resets_at": "2026-05-01T00:00:00.000Z",
      "check_skipped": false
    },
    "provider_metadata": {
      "import_job_id": "01J..."
    }
  },
  "tip": null
}
```

| Field | Notes |
|---|---|
| `data.contact_id` | Provider-facing stable id where possible. SendGrid and Mailchimp return `md5(lowercase(trim(email)))`; Mailchimp's native subscriber id is exactly that hash. Klaviyo returns the Klaviyo profile id. |
| `data.action` | One of `"created"`, `"updated"`, `"upserted"`. Some provider APIs cannot distinguish create from update in their upsert response. **Tolerate all three.** |
| `data.fields_auto_created` | Custom-field names that did not exist at the provider before this call and were created on the agent's behalf. Empty array if everything was already mapped. For Klaviyo this is always empty because profile properties do not require pre-registration. |
| `meta.provider_metadata.import_job_id` | SendGrid only. The underlying async import job id from `PUT /v3/marketing/contacts`, surfaced for debugging after `data.contact_id` was normalized per D33. |
| `meta.job` | Optional. Present when SendGrid accepts a contact import asynchronously. Treat `status: "pending"` as accepted provider work; immediate `get_contact` can return `found: false` until SendGrid finishes the import, so retry readback later before replaying. |
| `meta.request_id` | ULID. Echo this back in support requests. |
| `meta.quota.check_skipped` | Optional. `true` only when both Redis and Postgres were unreachable and the meter failed open. When present, treat `meta.quota.used` as a lower bound, not authoritative. |
| `meta.test_mode` | Optional. `true` when the request was made with a `vk_test_` key. The provider was not called. |
| `tip` | Optional human-readable hint for the agent (e.g., first-time-field guidance). `null` for most calls. |

#### Error responses

All errors share the envelope:

```json
{
  "status": "error",
  "error": {
    "code": "FIELD_TYPE_MISMATCH",
    "message": "SendGrid field 'loyalty_tier' is declared as Number but received string 'gold'.",
    "category": "user_input",
    "retry_safe": false,
    "provider": "sendgrid",
    "fix": {
      "action": "supply_matching_type",
      "hint": "Pass a numeric value or rename the property to avoid the existing field."
    }
  },
  "meta": {
    "request_id": "01JD5K9X7M0Q8V0QY4FYVQ3WZV",
    "total_latency_ms": 87
  }
}
```

`error.category` is one of `user_input`, `provider_fault`, `transient`, `system`. `error.retry_safe` is the agent's signal: if `true`, retrying with the same request body may succeed; if `false`, the agent must change something (input, credentials, plan).

# Vorlek error codes

Vorlek API errors use a stable envelope: `status: "error"`, an `error` object with `code`, `message`, `category`, and `retry_safe`, plus `meta.request_id` for support/debugging. `retry_safe` means the same request may succeed later if the caller waits or fixes the referenced limit; it does not mean retries are unlimited.

| Code | HTTP | retry_safe | Category | Trigger | Recovery hint |
|---|---:|---:|---|---|---|
| `AUTH_MISSING` | 401 | false | `user_input` | Missing or malformed `Authorization` header | Set `Authorization: Bearer vk_live_...` |
| `AUTH_INVALID` | 401 | false | `user_input` | Bad, expired, or wrong-mode Vorlek API key | Verify or rotate the API key |
| `AUTH_REVOKED` | 401 | false | `user_input` | API key was revoked | Rotate the API key and retry with the new value |
| `AUTH_FORBIDDEN` | 403 | false | `user_input` | Valid key lacks required provider scope | Re-issue/reconnect credentials with the required scope |
| `EMAIL_TAKEN` | 409 | false | `user_input` | Account signup email already exists | Sign in or use a different email |
| `ACCOUNT_NOT_FOUND` | 404 | false | `user_input` | Authenticated account row no longer exists | Contact support with `request_id` |
| `PROVIDER_ALREADY_CONNECTED` | 409 | false | `user_input` | Provider connection already exists for the account | Disconnect/rotate the existing connection first |
| `PROVIDER_AUTH_INVALID` | 400 | false | `user_input` | Provider rejected stored credentials at runtime (401 from upstream) | Reconnect the provider with a valid key and required scopes |
| `CONNECTION_NOT_FOUND` | 404 | false | `user_input` | Tool called with a provider that has no connection | Run `vorlek connect <provider>` first |
| `CONNECTION_INVALID` | 400 | false | `user_input` | Connection exists but is inactive or points at missing provider resources | Reconnect or rotate provider credentials |
| `CONNECTION_DECRYPT_FAILED` | 500 | false | `system` | Stored credentials could not be decrypted | Reconnect provider; Vorlek should investigate by `request_id` |
| `INVALID_PARAMS` | 400 | false | `user_input` | JSON parse or schema validation failed | Fix the request body/flags and retry |
| `FIELD_TYPE_MISMATCH` | 400 | false | `user_input` | Property value does not match the provider field type | Match the existing type or use a new property name |
| `NOT_FOUND` | 404 | false | `user_input` | Requested provider/API resource was not found | Check the resource identifier |
| `PAYLOAD_TOO_LARGE` | 413 | false | `user_input` | Request body exceeded provider/API limits | Split or reduce the payload |
| `TOOL_NOT_SUPPORTED` | 501 | false | `user_input` | Tool/provider pair is not implemented by Vorlek | Use a supported provider/tool pair |
| `TOOL_NOT_CONFIGURED` | 400 | false | `user_input` | Reserved for Phase 5+ Mandrill/config-gated tools | Reconnect with required extra configuration once supported |
| `QUOTA_EXCEEDED` | 429 | true | `user_input` | Plan period quota exhausted | Wait for reset or upgrade plan |
| `RATE_LIMITED` | 429 | true | `user_input` | Vorlek-side token bucket rejected a burst | Honor `Retry-After` header |
| `IDEMPOTENCY_CONFLICT` | 409 | false | `user_input` | Same `Idempotency-Key` reused with a different request body | Use a fresh idempotency key |
| `PROVIDER_RATE_LIMITED` | 429 | true | `transient` | Upstream provider returned 429 | Honor provider retry hints or exponential backoff |
| `PROVIDER_UNAVAILABLE` | 503 | true | `transient` | Upstream 5xx, network error, or timeout | Retry after backoff |
| `PROVIDER_FAILED` | 502 | false | `provider_fault` | Async provider job reached terminal failure | Inspect provider-side reason, fix, then retry |
| `CIRCUIT_OPEN` | 503 | true | `transient` | Per-account / per-provider circuit breaker is open after sustained provider failures | Honor `Retry-After`; the breaker self-recovers via 3 consecutive successful probe calls |
| `INTERNAL_ERROR` | 500 | true | `system` | Uncaught exception or unexpected state | Server bug; report `request_id` |

**Lock note** — Frozen-additive through v1.x — adding new codes is allowed; renaming, removing, or changing semantics of existing codes is a major-version bump.

**Recovery patterns for agents**

Transient retry:

```python
if error["retry_safe"]:
    sleep_with_backoff()
    retry()
```

Auth rotation:

```python
if error["code"] in {"AUTH_INVALID", "AUTH_REVOKED"}:
    refresh_or_rotate_api_key()
    retry_once()
```

Quota upgrade:

```python
if error["code"] == "QUOTA_EXCEEDED":
    show_upgrade_or_wait_until(error_meta["quota"]["resets_at"])
```

### `get_connection_status`

Freshly validates a connected provider and returns a normalized status.

**Endpoint:** `POST /v1/tools/get_connection_status`

#### Input schema (JSON)

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["provider"],
  "properties": {
    "provider": { "type": "string", "enum": ["sendgrid", "mailchimp", "klaviyo"] }
  }
}
```

#### Success response data

```json
{
  "provider": "sendgrid",
  "status": "active",
  "last_validated_at": "2026-04-26T00:00:00.000Z",
  "account_info": {
    "account_name": "Vorlek Dogfood"
  }
}
```

`status` is one of `active`, `invalid`, or `unreachable`. Missing connections return `CONNECTION_NOT_FOUND`.

### `send_transactional`

Sends one synchronous transactional email where the provider can honestly represent that operation.

**Endpoint:** `POST /v1/tools/send_transactional`

#### Input schema (JSON)

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["to", "subject"],
  "properties": {
    "provider": { "type": "string", "enum": ["sendgrid", "mailchimp", "klaviyo"] },
    "to": { "type": "string", "format": "email", "maxLength": 320 },
    "from": { "type": "string", "format": "email", "maxLength": 320 },
    "subject": { "type": "string", "maxLength": 998 },
    "template_id": { "type": "string", "maxLength": 255 },
    "html": { "type": "string", "maxLength": 1000000 },
    "text": { "type": "string", "maxLength": 1000000 },
    "variables": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } }
  }
}
```

Provide either `template_id` or `html`/`text`, not both. SendGrid is supported. Mailchimp returns `TOOL_NOT_SUPPORTED` per D34 because Mandrill is not shipped in Phase 2. Klaviyo returns `TOOL_NOT_SUPPORTED` per D19 because its transactional model is Flow-triggered and async.

#### Success response data

```json
{ "provider": "sendgrid", "message_id": "abc123", "action": "sent" }
```

### `get_campaign_stats`

Returns normalized campaign stats for the requested provider campaign id.

**Endpoint:** `POST /v1/tools/get_campaign_stats`

#### Input schema (JSON)

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["campaign_id"],
  "properties": {
    "provider": { "type": "string", "enum": ["sendgrid", "mailchimp", "klaviyo"] },
    "campaign_id": { "type": "string", "minLength": 1, "maxLength": 255 }
  }
}
```

#### Success response data

```json
{
  "provider": "sendgrid",
  "campaign_id": "camp_123",
  "sent": 100,
  "opens": 57,
  "clicks": 12,
  "bounces": 2,
  "unsubscribes": 1,
  "period": { "start": "2026-04-01T00:00:00.000Z", "end": "2026-04-26T00:00:00.000Z" }
}
```

## Rate limits

### Free tier

- **1000 successful operations per UTC calendar month** per account.
- The meter increments **once per gated tool call** (currently only `upsert_contact`). `vk_test_` calls and validation failures (Zod reject) do **not** count.
- Quota state is returned in `meta.quota` on every success and on `QUOTA_EXCEEDED` errors. `resets_at` is the start of the next UTC month.

### Headers

Phase 1 returns quota in the response envelope only; HTTP `X-RateLimit-*` headers are reserved for Phase 2.

### Account usage

`GET /v1/account/usage` (auth-required, does **not** consume quota):

```json
{
  "status": "success",
  "data": {
    "period": { "id": "2026-04", "start": "2026-04-01T00:00:00.000Z", "end": "2026-05-01T00:00:00.000Z" },
    "quota": { "used": 42, "limit": 1000, "resets_at": "2026-05-01T00:00:00.000Z" },
    "recent_operations": { "total": 50, "errors": 8, "by_tool": { "upsert_contact": 50 } }
  },
  "meta": { ... }
}
```

## Provider notes

| Provider | Connect | `upsert_contact` notes |
|---|---|---|
| SendGrid | `vorlek connect sendgrid --api-key SG.xxxxx` | Creates missing custom fields. `data.contact_id` is `md5(lowercase(trim(email)))`; the SendGrid import job id is in `meta.provider_metadata.import_job_id`. |
| Mailchimp | `vorlek connect mailchimp --api-key xxxxx-us7 --list-id a1b2c3d4e5` | Requires an audience/list. If exactly one audience exists, the API can auto-detect it. `data.contact_id` is the subscriber hash: `md5(lowercase(trim(email)))`. |
| Klaviyo | `vorlek connect klaviyo --api-key pk_xxxxx` | Uses pinned API revision `2026-04-15`. `data.contact_id` is Klaviyo's profile id. `data.fields_auto_created` is always `[]` because Klaviyo accepts arbitrary profile properties. |

Provider/tool coverage is documented at https://vorlek.dev/docs/providers.

# Heartbeat

Use this file when you need a low-cost periodic health check for a Vorlek-enabled agent.

## Status sources

| Source | URL | Use |
|---|---|---|
| Vorlek docs skill | `https://vorlek.dev/SKILL.md` | Current agent-facing contract and import hashes. |
| API health | `https://api.vorlek.com/health` | Fast service liveness check. |
| Skill version | `https://api.vorlek.com/v1/skill/version` | Compare against the local `version` in `SKILL.md`. |
| Public status | `https://status.vorlek.com` | Forward pointer; the dedicated status page lands in Phase 3.8. |
| CI live | `https://github.com/vorlek/vorlek-api/actions/workflows/ci-live.yml` | Last-green pointer for live cross-provider canaries once Phase 3.7 hardening lands. |

## Recommended cadence

Run this check at most once per day unless a user is actively debugging an outage:

1. Fetch `https://api.vorlek.com/health`.
2. Fetch `https://api.vorlek.com/v1/skill/version` and compare it with the saved `SKILL.md` `version`.
3. If the version changed, refetch `https://vorlek.dev/SKILL.md` and the component files under `https://vorlek.dev/skill/`.
4. For each provider the account uses, call `get_connection_status` with the saved Vorlek API key.
5. Report only material changes to the human.

## Agent report format

If all providers are healthy:

```text
VORLEK_OK - Checked Vorlek; providers active and skill version current.
```

If a provider needs action:

```text
VORLEK_ACTION_NEEDED - <provider> connection is <status>. Ask the human to reconnect or rotate credentials.
```

If the API or docs cannot be reached:

```text
VORLEK_ERRORS - Vorlek heartbeat failed at <source>. Retry with backoff, then escalate with timestamps.
```

Do not send the Vorlek API key or provider credentials to any domain other than `api.vorlek.com`.

## Versioning

- **Skill version:** `0.3.0` (this file). Pre-`v1.0.0`, additive changes are unannounced; breaking changes are called out in the changelog and accompanied by a bump.
- **CLI package version:** `@vorlek/cli` is versioned independently from this SKILL file and the API/docs release marker. Check the installed CLI with `vorlek --version` or the public package with `npm view @vorlek/cli version`.
- **API version:** path-prefixed `/v1`. Pre-`v1.0.0` lock, the `success` and `error` envelope shapes may gain fields but will not remove or rename them. Agents must ignore unknown fields.
- **Live freshness:** `GET https://api.vorlek.com/v1/skill/version` returns the canonical version string for the deployed API. Refresh the skill if it drifts from the local `version` field.
- **Changelog:** `0.3.0` adds `get_connection_status`, `send_transactional`, and `get_campaign_stats`; Mailchimp/Klaviyo transactional sends are explicit `TOOL_NOT_SUPPORTED`.

## Support

- **Email:** `support@vorlek.com` — include `meta.request_id` from the failing response.
- **Security:** `security@vorlek.com` — for credential exposure, encryption-at-rest concerns, or vulnerability reports.
- **Status / changelog:** https://vorlek.dev/changelog
- **Docs (full):** https://vorlek.dev

GitHub issues will open in Phase 3 alongside SDK + MCP. Until then, email is the canonical channel.
