---
name: vorlek
version: 0.1.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]
capabilities: [upsert_contact]
changelog: https://vorlek.dev/changelog
---

# 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. Phase 1 ships SendGrid only and a single tool — `upsert_contact` — 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 auto-created the first time they appear; the agent does not need to register fields ahead of time.

## Quickstart

Three commands from a fresh terminal:

```bash
# 1. Install the CLI (Phase 1: from source)
git clone https://github.com/vorlek/vorlek-cli && cd vorlek-cli && pnpm install && pnpm build && pnpm link --global

# 2. Sign up — emits a live key (saved to ~/.vorlek/config.json) and a one-time test key
vorlek auth signup

# 3. Connect SendGrid and upsert your first contact
vorlek connect sendgrid --api-key SG.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 '{"email":"jamie@example.com","first_name":"Jamie","properties":{"plan":"free"}}'
```

## 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 | Short-circuits at the API boundary: validates input, returns a mocked envelope with `meta._test_mode: true`, **never calls the provider, never decrypts credentials**. Free of quota cost. |

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`.

## 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"], "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.

#### Success response (HTTP 200)

```json
{
  "status": "success",
  "data": {
    "contact_id": "f1ec8680-...",
    "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
    }
  },
  "tip": null
}
```

| Field | Notes |
|---|---|
| `data.contact_id` | Provider-native id. For SendGrid in Phase 1 this is the `job_id` from PUT `/v3/marketing/contacts` (Phase 2 may resolve to the canonical contact id). |
| `data.action` | One of `"created"`, `"updated"`, `"upserted"`. SendGrid's PUT cannot distinguish create from update, so it always returns `"upserted"`. Phase 2 adapters (Mailchimp, Klaviyo) return the narrower `created`/`updated`. **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. |
| `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).

## Errors

Codes the `upsert_contact` endpoint can return. Every code includes `category` + `retry_safe` so an agent can branch without parsing prose.

| HTTP | Code | Category | retry_safe | Agent recovery |
|---|---|---|---|---|
| 400 | `AUTH_MALFORMED` | user_input | false | Authorization header missing, not Bearer, or key fails the `vk_(live\|test)_…` regex. Re-acquire a key. |
| 401 | `AUTH_INVALID` | user_input | false | The Vorlek key prefix matched a row but bcrypt comparison failed. The key is wrong. Re-acquire. |
| 401 | `AUTH_REVOKED` | user_input | false | Key was rotated. `fix.action: rotate_api_key`. Use the new key. |
| 400 | `INVALID_PARAMS` | user_input | false | Zod rejected the body. `error.message` carries `<field>: <reason>`. |
| 429 | `QUOTA_EXCEEDED` | user_input | false | Account hit the period limit. `meta.quota` populated. `fix.action: upgrade`. |
| 400 | `CONNECTION_MISSING` | user_input | false | No active connection for `provider`. Call `POST /v1/connections` first. |
| 400 | `CONNECTION_NOT_ACTIVE` | user_input | false | Connection was soft-disconnected. Reconnect. |
| 401 | `AUTH_INVALID` (provider) | provider_fault | false | The stored provider key is no longer valid. `fix.action: reconnect`. Reconnect. |
| 403 | `AUTH_SCOPE_INSUFFICIENT` | provider_fault | false | Provider key lacks the scope. `fix.action: expand_scope` (e.g., SendGrid Marketing). |
| 400 | `FIELD_TYPE_MISMATCH` | user_input | false | A `properties` value's type doesn't match the existing provider field. Change value type, or rename the property. |
| 400 | `PAYLOAD_TOO_LARGE` | user_input | false | Provider rejected the size. `fix.action: reduce_payload_size`. |
| 404 | `NOT_FOUND` | user_input | false | Provider returned 404 (rare on upsert; possible on reference lookups). |
| 429 | `PROVIDER_RATE_LIMITED` | transient | true | Provider's rate limit. Honor `fix.hint` (often a retry-after window). |
| 504 | `PROVIDER_TIMEOUT` | transient | true | Network or provider timeout. Safe to retry with backoff. |
| 502 | `PROVIDER_UNAVAILABLE` | transient | true | DNS / TLS / 5xx. Safe to retry. |
| 500 | `CONNECTION_DECRYPT_FAILED` | system | false | Server-side AES failure. File a support ticket with `request_id`. |
| 500 | `INTERNAL_ERROR` | system | true | Unhandled error path. Retry once; then escalate with `request_id`. |

When `error.retry_safe` is `true`, the agent should retry with exponential backoff (start ≥ 500 ms; cap at 30 s) and obey any `Retry-After` hints in `error.fix.hint`. When `false`, retrying without changing input or credentials will return the same error.

## 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": { ... }
}
```

## Versioning

- **Skill version:** `0.1.0` (this file). Pre-`v1.0.0`, additive changes are unannounced; breaking changes are called out in the changelog and accompanied by a bump.
- **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.
- **Phase 2 (planned):** Mailchimp + Klaviyo adapters, additional tools (`send_transactional`, `get_campaign_stats`, `list_*`), TypeScript SDK. Behavior under existing tools will not break.

## 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.
