Docs / API
Workforce API and agent CLI
An automated agent operates a 1PVN tenant two ways: over HTTP through the Workforce API, or directly against the database through the 1pvn CLI. The HTTP API is the surface for an untrusted node (e.g. an email-processing worker) — it never touches the database and every write is bearer-authenticated, tenant-scoped, and audited. The CLI is the surface for a trusted operator on a machine that already holds a database URL. Both are non-interactive and JSON-only.
The most important rule on both surfaces: an agent can propose a new contact, but a contact only goes live after a human approves it. See Organizations and contacts for the staff-side review queue.
Purpose: Reference for anyone wiring an AI worker, script, or service into a tenant's CRM.
Audience: Integrators building agent tooling, ops staff operating a service account, and reviewers auditing what an automated worker can and cannot do.
Prerequisites: A provisioned staff-role service account in the target tenant, its Supabase credentials, and familiarity with HTTP status codes and JSON. For the CLI: repo checkout, pnpm install, and a tenant database URL.
Workforce HTTP API
Base path: /api/workforce. Every route is bearer-authenticated and resolves its tenant from the request host — the same host→tenant rule as the rest of the app (intcrm.1pvn.com → the intcrm tenant). These routes are enumerated in the auth proxy's public-path list so the cookie session middleware doesn't bounce them to /login; each route authenticates itself.
Authentication
Pass a Supabase access token as a bearer header:
Authorization: Bearer <access_token>
Mint the token with a password grant against the tenant's Supabase project:
POST {SUPABASE_URL}/auth/v1/token?grant_type=password
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json
{ "email": "<service-account-email>", "password": "<password>" }
The response's access_token is the bearer. Tokens expire — on any 401, re-mint once and retry.
Authorization model. The token's Supabase user must be a non-archived staff-role member of the host's tenant. Roles allowed: staff, reviewer, lead, admin. The role is read from the users table, not the token — it cannot be spoofed. Because the service account exists only in the tenants it may act on, a token valid for one tenant returns 403 on every other tenant's host. There is no feature flag: access follows account membership.
Endpoints
| Method + path | Purpose | Min role |
|---|---|---|
GET /api/workforce/contacts | List/search contacts (active only) | staff |
POST /api/workforce/contacts | Propose a new contact (held for approval) | staff |
POST /api/workforce/contacts/{id}/approve | Approve a proposal → contact goes live | lead |
POST /api/workforce/contacts/{id}/reject | Reject a proposal | lead |
GET /api/workforce/audiences | List campaign audience segments | staff |
POST /api/workforce/activities | Log an activity against a contact or org | staff |
Only active contacts are ever returned or writable — proposed and rejected contacts are invisible to reads, search, campaign audiences, and activity targets until approved.
GET /api/workforce/contacts
Query params: q (name/email search), organizationId (UUID), limit (1–200, default 50). Returns the tenant's active contacts.
{ "ok": true, "data": [ { "id": "…", "firstName": "…", "lastName": "…", "email": "…" } ] }
A non-UUID organizationId returns 400 invalid_organizationId.
Rate limit: 120/min per service account.
POST /api/workforce/contacts — propose a contact
A proposed contact never enters the CRM live. It lands as review_status = 'proposed', notifies the tenant's approvers (admins and leads), and waits for a human decision.
Request body:
{
"firstName": "Maria",
"lastName": "Santos",
"email": "maria@acme.ph",
"title": "Head of Investments",
"organizationId": "<uuid, optional>",
"sourceNote": "Signed email from acme.ph, asked for a call"
}
firstName is required; sourceNote (≤500 chars) is the "why" shown to the human approver. email, phone, title, organizationId are optional.
Response — proposed (HTTP 201):
{ "ok": true, "status": "proposed", "message": "Proposed — awaiting human approval.",
"data": { "id": "…", "reviewStatus": "proposed" } }
Response — duplicate (HTTP 200): an exact-email match already exists, so nothing is proposed:
{ "ok": true, "status": "duplicate",
"message": "A contact with this email already exists — not proposed.",
"existing": { "id": "…", "name": "…", "email": "…" } }
Never log an activity against a proposed id — it is not active, so the activity endpoint returns 404 until it is approved.
Rate limit: 60/min.
POST /api/workforce/contacts/{id}/approve · /reject
Applies a human decision to a proposed contact. Requires the lead (or admin) role — the staff-level propose token is rejected with 403 forbidden_needs_approver. This split is deliberate: it lets a worker relay an approval tap (e.g. from Telegram) without ever being able to self-approve the contacts it proposed. See the two-token model below.
approve→ sets the contactactive(live in the CRM), returns{ "ok": true, "status": "active", "data": { "id": "…" } }.reject→ sets the contactrejected, returns{ "ok": true, "status": "rejected", … }.
Both only act on a row still in proposed state (idempotent). A second tap, or a bad/foreign id, returns 404 not_found_or_already_handled. A non-UUID id returns 400 invalid_id.
Rate limit: 60/min.
POST /api/workforce/activities
Logs an interaction against a contact or org. The activity is attributed to the service actor and written to audit_events.
Request body:
{
"contactId": "<uuid>",
"direction": "outbound",
"channel": "email",
"activityType": "sent",
"subject": "Intro call follow-up",
"bodyPreview": "Thanks for the time today…"
}
Exactly one of contactId / orgId is required. direction: outbound | inbound. channel: email | whatsapp | phone | meeting | social | sms | note | system. activityType: sent | received | opened | clicked | replied | shared | called | met | noted | bounced | unsubscribed | system. Optional metadata object, capped at 8 KB.
A target that doesn't exist in the tenant (or an inactive contact) returns 404 target_not_found — not a leaky 500. Success returns the activity row at HTTP 201.
Rate limit: 60/min.
GET /api/workforce/audiences
Read-only. Returns the tenant's campaign audience segments (the CLI has no audience surface). Rate limit: 120/min.
Rate limiting
All routes are keyed on tenant:service-account with a per-minute sliding window, plus a pre-auth IP throttle (60/min per client IP) that bounds token-guessing before the token is even validated. Limits are enforced by Upstash Redis. On a Redis outage the limiter fails open (allow-all) — the bearer check remains the real gate. Rate-limited requests return 429 rate_limited.
Error shape
Success is { "ok": true, … }; failure is { "ok": false, "error": "<code>" } with a matching HTTP status. Common codes: missing_bearer_token (401), invalid_token (401), forbidden (403, not a staff member of this tenant), forbidden_needs_approver (403, approval attempted with a non-lead token), unknown_tenant (404), validation (400, with details), rate_limited (429).
The two-token privilege split
For a worker that relays approvals (e.g. an email worker that proposes contacts and forwards an approve/reject button to a human over Telegram), provision two service accounts:
| Token | Role | Used for | Never used for |
|---|---|---|---|
| Propose/read token | staff | list, search, log activities, propose contacts | approvals |
| Approver token | lead | approve/reject, inside the human-tap handler only | any path that reads untrusted input (email bodies, LLM output) |
The propose token is 403 on the approve/reject endpoints, so even a prompt-injected worker cannot make a contact live. Approval is triggered only by a human action; the approver token appears nowhere else. The full operating specification for such a worker lives at docs/WORKFORCE-AI-WORKER-PROMPT.md in the repo.
The 1pvn agent CLI
For a trusted operator on a machine that already holds a tenant database URL, the repo ships a CLI at bin/1pvn. It talks to the database directly (no HTTP), is non-interactive, and emits JSON only — success on stdout, errors as JSON on stderr — so it is safe to drive from a script or another agent.
Setup
pnpm install # provides tsx, which the launcher runs under
1PVN_DATABASE_URL=postgres://… # tenant database connection (SSL; NODE_ENV=production forces it)
1PVN_TENANT=intcrm # tenant subdomain or id
1PVN_ACTOR_USER_ID=<uuid> # the users.id the writes are attributed to
ONEPVN_* aliases are accepted for all three. Variables can also be supplied via an env file with --env-file <path>.
Usage
1pvn <group> <verb> [--flag value | --stdin] [--dry-run]
Command groups: contacts (alias leads), companies, deals, activities, meetings, tickets, tags, capture. Run 1pvn --help for the group list, 1pvn <group> --help for its verbs.
--stdin— read the input object as JSON from stdin instead of--flagpairs.--dry-run— validate and report what would happen without writing.--version— print the CLI version as JSON.
Output is always { "ok": true, "command": "<group>.<verb>", … } or, on failure, { "ok": false, "command": "…", "error": { "code": "…", "message": "…" } }. Every write runs inside the resolved tenant context and is attributed to 1PVN_ACTOR_USER_ID.
API vs CLI — which to use
| Workforce API | 1pvn CLI | |
|---|---|---|
| Transport | HTTP (bearer) | Direct database |
| Runs on | Untrusted node (no DB access) | Trusted operator machine |
| Auth | Supabase token, role from DB | Holds the DB URL |
| Surface | contacts (read/propose/approve), audiences, activities | full CRM: contacts, companies, deals, meetings, tickets, tags, capture |
| New contacts | proposed → human approval | direct write (operator is trusted) |
Use the API for anything that processes untrusted input or runs where a database URL must not live. Use the CLI for trusted bulk operations and CRM surfaces the API doesn't expose.
Related
- API and webhooks overview — the full public + staff-gated + webhook surface.
- Organizations and contacts — the staff-side contact review queue.
- Roles and access — what each role can do.