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 + pathPurposeMin role
GET /api/workforce/contactsList/search contacts (active only)staff
POST /api/workforce/contactsPropose a new contact (held for approval)staff
POST /api/workforce/contacts/{id}/approveApprove a proposal → contact goes livelead
POST /api/workforce/contacts/{id}/rejectReject a proposallead
GET /api/workforce/audiencesList campaign audience segmentsstaff
POST /api/workforce/activitiesLog an activity against a contact or orgstaff

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 contact active (live in the CRM), returns { "ok": true, "status": "active", "data": { "id": "…" } }.
  • reject → sets the contact rejected, 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:

TokenRoleUsed forNever used for
Propose/read tokenstafflist, search, log activities, propose contactsapprovals
Approver tokenleadapprove/reject, inside the human-tap handler onlyany 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 --flag pairs.
  • --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 API1pvn CLI
TransportHTTP (bearer)Direct database
Runs onUntrusted node (no DB access)Trusted operator machine
AuthSupabase token, role from DBHolds the DB URL
Surfacecontacts (read/propose/approve), audiences, activitiesfull CRM: contacts, companies, deals, meetings, tickets, tags, capture
New contactsproposed → human approvaldirect 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