Portal Ops

Portal Ops
API Guide

How org admins issue API keys and how trusted assistants call Pezley Portal for morning briefs, leads, jobs, proposals, and scheduling — without signing in as a user.

Public reference · Keys are org-scoped · You choose which scope groups each key may use.

What's Inside

  1. Quick start
  2. OpenAPI spec
  3. Getting started
  4. What is Portal Ops?
  5. Issue & rotate keys
  6. ChatGPT / MCP connection
  7. Authentication & HTTP surface
  8. Actions: brief, exec, company-state, log
  9. Morning brief shape
  10. Scope groups (full tool lists)
  11. AI assistants (integrator playbook)
  12. Estimates & exclusions
  13. Proposals
  14. Jobs, delays & limbo
  15. Staff Home File
  16. Owner finance scope
  17. Import from another app
  18. Job naming
  19. Blocked tools
  20. Operator responsibility

Quick start

Tool arguments go in input, never in args. Top-level orgId must match the org baked into the key.

{
  "action": "exec",
  "orgId": "YOUR_ORG_ID",
  "toolName": "list_jobs",
  "input": {}
}

Before working on an estimate, call read_estimate_handoff to check staff and prior-agent context, then call estimate_snapshot. If you do not have a job id yet, call list_jobs (or action: "brief") first, then estimate_snapshot or read_proposal with input.jobId from that list. estimate_context, update_estimate, and link_recording are live. estimate_context needs a full-access key (*). Do not invent ids. If the API returns job-not-found, the message includes the identifier that was tried — call list_jobs and retry.

For trials, ask your organization administrator for an approved sandbox with synthetic data and the minimum required access. Keep sandbox login details and keys in your authorized secret store, never in public documentation or chat.

restore_proposal_revision is PR #1239 (not on main yet). Batch and view: "summary" are PR #1240 (not on main yet). Send one exec per request until batch ships.

ChatGPT — one-click Connect

  1. Add a custom connector. MCP server URL https://pezleyportal.com/ops-connect. OAuth client ID is pezley-chatgpt. Leave the client secret blank.
  2. Redirect URI, exact: https://chatgpt.com/connector_platform_oauth_redirect. Portal returns iss so ChatGPT can use this stable callback. https://chatgpt.com/connector/oauth/… is a different URI and is rejected.
  3. Sign in as an org admin and approve the scopes. Owner finances and app import are never granted here. The key page shows “ChatGPT, connected by <name>”. Revoke it to disconnect immediately.
  4. Paste this into a new chat: tool arguments always go in the JSON field "input", never "args". Start by calling estimate_snapshot. For a safe trial, use the administrator-approved sandbox.

Claude — one-click Connect

Hosted Claude (claude.ai, desktop, mobile) uses the same server URL https://pezleyportal.com/ops-connect and client ID pezley-claude (no client secret). Redirect URI, exact, from Anthropic’s connector docs: https://claude.ai/api/mcp/auth_callback. Claude Code’s loopback redirect (http://localhost/callback on a changing port) is not a fixed URI, so it is not accepted. Approve scopes as an admin. Revoke “Claude, connected by <name>” on the key page to kill tokens immediately. Same starter prompt. For a safe trial, use the administrator-approved sandbox.

Generic MCP

One exec per request until PR #1240. Example tool: estimate_snapshot with "input": {} and the administrator-approved sandbox orgId for a safe trial. Same starter prompt as ChatGPT.

OpenAPI spec

Agents and integrators should use the generated OpenAPI 3.0 document for every exec tool name, JSON input shape, read vs write kind, and x-ops-scopes group. It is rebuilt from the Portal tool registry on each deploy.

Download YAML  ·  Download JSON  ·  https://pezleyportal.com/portal-ops-openapi.yaml

Browse below or paste the YAML URL into your agent framework. No login required.

Getting started

  1. Read the OpenAPI spec (or Redoc below) — every exec tool has a PortalOpsInput_* schema, scope tags, and write tools include x-examples.
  2. As an org admin, issue a ppops_… key under Settings → Portal Ops keys. New keys are opt-in: no scopes are checked until you pick groups (or Full access for every API group). Choose the smallest set that fits — e.g. briefing-read only for a morning snapshot.
  3. Store the secret in your assistant env (e.g. PEZLEY_OPS_BRIEF_KEY). Never commit it to git or paste it into customer-visible channels.
  4. Call POST https://pezleyportal.com/.netlify/functions/portal-ops-mrz-tool with Authorization: Bearer … and a JSON body. Top-level orgId must match the org baked into the key — not inside input.
  5. Use action: "brief" for a one-shot morning snapshot, or action: "exec" with toolName + input for a single registry tool per request. Put tool arguments in input, not args.
  6. On 403 / 400, read the code field and adjust scopes or tool choice — see error codes, scope groups, and blocked tools.

What is Portal Ops?

Portal Ops is Pezley Portal's HTTP API for external AI assistants, automations, and integrations. Each request is authenticated with a long-lived ppops_… secret your org admin issues in Settings.

Typical uses: a morning operations brief, adding a lead from a phone call, booking an estimate visit, updating a proposal draft, posting a weather delay, or attaching a work-order PDF.

Signing in to the web app as an org admin still unlocks the full in-browser admin assistant catalog. Portal Ops keys are separate credentials with scopes you control.

Issue & rotate keys (org admins)

  1. Sign in at pezleyportal.com as an org admin.
  2. Open Settings → Portal Ops keys (or ?adminTab=opsbrief).
  3. Name the key (e.g. “Morning brief”, “Phone lead bot”). Scopes for new key start with none checked — enable only the groups this automation needs. High-impact groups (owner $, send proposal, delete job files, crew clock, and some job-ops groups) ask for confirmation when you turn them on.
  4. Optional: Full access grants every Portal Ops scope after a confirmation dialog; the key stores ["*"] (same effective access as a legacy full key). You cannot Generate ops key with zero scopes selected — pick at least one group or Full access.
  5. Copy the ppops_… value once into the assistant's secret store (often PEZLEY_OPS_BRIEF_KEY).
  6. Use Save scopes on existing named keys, or Rotate / Revoke per key; other keys keep working. After scope changes, reconnect ChatGPT if you use MCP.

Legacy keys: Older single-key installs and named rows with no scopes field keep full Portal Ops (all groups, including owner finance). Orgs that still show one row with keyId: "legacy" have full access; scopes are not editable — issue a new named key to restrict access.

POST https://pezleyportal.com/.netlify/functions/ops-brief-key
Authorization: Bearer <Firebase org-admin ID token>
{ "action": "generate-key", "orgId": "your-org-id", "label": "Production assistant",
  "scopes": ["briefing-read", "proposals-estimates"] }
# Full access: "scopes": ["*"]
# Missing or empty scopes[] → 400 scopes-empty
Never commit secrets
Portal stores only a hash of each key. If a key leaks, revoke it immediately and issue a new one.

ChatGPT / MCP connection (org admins)

In Settings → Portal Ops keys, the ChatGPT / MCP connection card controls how long hosted ChatGPT OAuth sessions last for new connects. Default: until the Ops key is revoked or rotated (grantTtlMode: until_revoked) — not a 30-day calendar timer.

Scopes vs OAuth: current key scopes are checked on each request, alongside OAuth scope and applicable consent caps. Refresh tools updates metadata; reconnect ChatGPT through normal consent to restore or approve access. See setup, discovery and consent and Estimate Library reads.

Optional: expires_after_days caps lifetime from each new Connect (1–3650 days). Existing ChatGPT sessions keep the deadline from when they connected.

Programmatic parity (Firebase org-admin ID token, not ppops_):

POST https://pezleyportal.com/.netlify/functions/ops-brief-key
{ "action": "key-status", "orgId": "your-org-id" }
→ keys[], availableScopes[], mcpConnection { grantTtlMode, expiresAfterDays }

{ "action": "save-mcp-connection", "orgId": "your-org-id", "grantTtlMode": "until_revoked" }
→ { ok, orgId, mcpConnection }

Tool calls still use POST https://pezleyportal.com/.netlify/functions/portal-ops-mrz-tool with your ppops_ key. See PortalMcpConnectionSettings in the OpenAPI spec.

Authentication & HTTP surface

ItemValue
Environment variablePEZLEY_OPS_BRIEF_KEY (or a named secret per assistant)
Authorization headerAuthorization: Bearer <your ppops key>
Alternate headerX-Pezley-Ops-Key: <your ppops key>
HTTP endpointPOST https://pezleyportal.com/.netlify/functions/portal-ops-mrz-tool (also OPTIONS for CORS)
Legacy aliasPOST …/openclaw-mrz-tool — deprecated thin re-export; migrate to portal-ops-mrz-tool.
Key managementPOST https://pezleyportal.com/.netlify/functions/ops-brief-key — Firebase org-admin ID token only (not ppops_). Actions: key-status (includes mcpConnection, availableScopes), save-mcp-connection, generate-key (label + required non-empty scopes[] — scope ids or ["*"]), update-key-scopes, rotate-key, revoke-key. Scope catalog: OpenAPI x-portal-ops-scope-appendix.
Org in bodyorgId must match the org embedded in the key (top-level JSON field, not inside input). Mismatch → 403 org-mismatch.
Rate limitOps keys: 60 requests / minute per key uid → 429 with retryMs. See OpenAPI x-portal-ops-http-notes.
Error shape{ error, code, hint?, toolName?, allowed? } — code enum in OpenAPI PortalOpsError.

Actions: brief, exec, company-state, log

Every request is JSON POST to the Portal Ops endpoint. The action field selects behavior. Signed-in Firebase org admins may use the same URL with an ID token for the full in-app tool catalog; ops keys are limited to registry tools allowed by their scopes.

brief

Morning operations snapshot. Requires briefing-read on ops keys. Optional sessionId, adminName for audit.

{
  "action": "brief",
  "orgId": "your-org-id"
}

exec

One registry tool per request. toolName must appear in OpenAPI x-portal-ops-tools; input must match PortalOpsInput_{toolName}.

{
  "action": "exec",
  "orgId": "your-org-id",
  "toolName": "create_lead",
  "input": { "name": "Example lead", "phone": "CUSTOMER_PHONE" }
}

company-state

Compact company memory + brief subset for multi-turn assistants (same auth as brief). Requires scopes that cover the underlying reads.

{
  "action": "company-state",
  "orgId": "your-org-id"
}

log

Append an audit line for an external assistant session (kind, message, optional tool list). Does not run Portal tools.

{
  "action": "log",
  "orgId": "your-org-id",
  "sessionId": "morning-bot",
  "kind": "assistant",
  "message": "Posted rain delay for JOB_ID"
}

Missing/invalid auth → 401 (missing-auth, invalid-ops-key). Scope or blocked tool → 403 with a named code. Malformed JSON → 400.

Morning brief shape

Successful action: "brief" returns ok: true and a brief object (OpenAPI PortalOpsBriefSnapshot). Fields are filtered by what your key can read — finance blocks stay out unless owner-finance is enabled.

asOf
ISO timestamp when the snapshot was built.
today
Calendar date YYYY-MM-DD in the org timezone.
orgId
Active org id (must match request).
todayJobs / weekJobs
Scheduled production jobs (shape aligns with list_jobs / dispatch).
onClock
Crew currently clocked in.
hours
Pay-period hour rollups per crew member.
crew
Roster snapshot for stand-up.
salesLeads
Hub “To build” leads when leads-crm or briefing reads allow.
proposals
Hub proposal list when proposal reads are in scope.
pipeline
Pipeline scoring summary when allowed.
pendingCheckins
Action Feed check-ins awaiting publish when action-feed is enabled.
learningAi
Learning AI study status for the org.

Use read_job / read_proposal via exec for detail beyond the brief list rows.

Scope groups (full tool lists)

When you issue or edit a key, you enable one or more groups below. New keys are opt-in in Settings (nothing checked until you choose). Legacy keys and rows with no scopes field behave like full Portal Ops (all groups, including owner finance). Keys with ["*"] or Full access match that same full catalog. An enabled group grants the full mutate surface for that product area — the same actions an org admin can take in the app via AI tools, not read-only subsets.

Each row lists every registry tool in that group — regenerated from the ops brief scope catalog on deploy.

Scope idLabelWhat it allows
briefing-read Morning brief & reads Jobs, crew, clock scans, learning AI, recurring reminders, check-ins. Home File, including gate codes, is a separate Home File grant.

Tools: analyze_time_trends, list_crew, list_jobs, list_learning_ai, list_recurring_reminders, list_teams, list_tools, read_job, scan_shifts
action-feed Tasks & alerts medium risk Full Action Feed cleanup for this org: list/approve pending check-ins, one-off reminders — same mutate surface as Today (not read-only).

Tools: add_reminder, list_pending_checkins, publish_checkins
company-state Company memory & inventory medium risk Company profile, lore (remember/forget), tools checkout, reminders.

Tools: add_reminder, add_tool, check_in_tool, check_out_tool, create_recurring_reminder, delete_recurring_reminder, delete_tool, edit_recurring_reminder, edit_tool, forget, mark_tool_fixed, mark_tool_repair, remember, toggle_recurring_reminder, update_company_info
leads-crm Leads & pipeline list_leads and get_lead return full website CRM records and linked consent docs (including phone numbers). Pipeline scoring, follow-up drafts, and create_lead. list_sales_leads is Sales hub to-build jobs, not these CRM records.

Tools: analyze_pipeline, create_lead, draft_followup, get_lead, list_leads, list_sales_leads, score_lead
proposals-estimates Proposals & estimates high risk Hub proposals, drafts, send to customer, estimate library.

Tools: add_estimate_note, analyze_rate_book, create_estimate, delete_estimate_descriptor, delete_estimate_product, download_proposal_pdf, estimate_context, estimate_snapshot, get_estimate_descriptor, get_estimate_product, link_recording, list_estimate_descriptors, list_estimate_products, list_proposals, list_recordings, read_estimate_handoff, read_proposal, read_recording_transcript, reopen_estimate_draft, reopen_proposal_draft, research_estimate_product, resend_proposal_email, restore_proposal_revision, save_estimate_settings, save_proposal_draft, send_proposal, unlink_recording, unsend_proposal_to_draft, update_estimate, upsert_estimate_descriptor, upsert_estimate_product
jobs-files Jobs & files high risk Create/edit jobs, uploads, work orders, delete files, rename, hours, assign tasks — full job file ops (use Job status & limbo scope for status-only keys). Does not include Home File gate codes, pets, formulas, or leftovers.

Tools: add_job, add_task_to_job, assign_to_job, attach_job_file, attach_work_order, create_job, delete_job_file, detach_job_file, edit_job, edit_summary, link_recording, remove_job_file, rename_job, set_job_colors, set_job_name, unlink_recording, upload_job_file
home-file Home File high risk Home File timeline (no prices) plus staff-only gate code, lockbox, access notes and pets, exact color formulas, and the leftover paint log. This grant is separate from Jobs & files, so an existing jobs-files key does not gain gate-code access. Gate codes and pets are never included in anything customer-facing.

Tools: read_home_file, set_home_file_access, set_home_file_formulas, set_home_file_leftovers
scheduling Scheduling & visits medium risk Crew Schedule / admin assistant parity: read jobs/crew/shifts, edit bars & spots, assign crew, estimate/client visits, delay posts, and schedule-related job fields (hours). write_schedule_with_ai stays in-app (Pro Tips panel) — use edit_job_schedule or visit tools from automations.

Tools: add_spot_assignment, analyze_time_trends, assign_to_job, book_client_visit, book_estimate_visit, cancel_client_visit, cancel_estimate_visit, edit_job, edit_job_schedule, list_appointments, list_crew, list_jobs, list_recurring_reminders, list_schedule, list_teams, offer_estimate_visit, polish_delay_reason, post_job_delay, read_job, remove_spot_assignment, scan_shifts
job-delays Job delay posts medium risk Post rain/heat/schedule delays (customer share + crew cascade) with optional polish_delay_reason — same as check-in Delay / Action Feed.

Tools: polish_delay_reason, post_job_delay
job-status Job status & limbo medium risk Set job status (active, accepted, limbo, completed, archived), limbo staff reason, general/T&M flags — includes polish_limbo_reason (Rewrite with AI) or custom text.

Tools: list_jobs, polish_limbo_reason, read_job, set_job_status
crew-clock Crew & time clock high risk Clock in/out, shifts, crew roster edits.

Tools: add_crew_member, add_shift_manual, clock_in_user, clock_out_user, delete_shift, edit_crew_member, edit_shift
production-rates Production rates Rate book list/get/upsert/delete.

Tools: delete_production_rate, get_production_rate, list_production_rates, upsert_production_rate
drips-comms Email & drips Templates and drip sequence drafts/updates.

Tools: create_drip_sequence, draft_drip_sequence, draft_email_template, list_drip_sequences, list_email_templates, update_drip_sequence, update_email_template
business-data Business data (read) Portal Ops data API (read-only, paged, filter by date / customer / job): leads, contacts, estimates, schedule, production, photos, messages, follow-ups, staff, hours, colors, reviews/referrals, settings, activity. Invoices / payments / job costing stay on owner-finance.

Tools: get_contact, get_estimate, get_job_production, get_lead, get_settings, list_activity, list_contacts, list_estimates, list_followups, list_hours, list_job_colors, list_leads, list_messages, list_photos, list_production, list_reviews_referrals, list_schedule, list_staff
photos Photos (upload & annotate) medium risk upload_photo / save_annotated_photo (new linked copy, original untouched) + list_photos. Every assistant photo is pending until staff approve it in Portal; approval is not available to keys.

Tools: annotate_photo, download_photo, list_pending_share_photos, list_photos, save_annotated_photo, upload_photo
webhooks Event webhooks medium risk list_webhooks / create_webhook / delete_webhook — HMAC-signed POSTs for lead.created, customer.replied, proposal.viewed, proposal.signed, payment.received.

Tools: create_webhook, delete_webhook, list_webhooks
owner-finance Owner $ — Numbers & job costing high risk Margin, burden, CFO packs, bank inbox/reconciliation, invoices, payments, job costing, save_numbers_settings, save_job_costing, record_collected_payment, receipts → job costing (ingest/confirm/list/void).

Tools: advise_cfo, analyze_numbers, cfo_setup_status, confirm_receipt, draft_cfo_report, finish_bank_reconciliation, get_invoice, ingest_receipt, list_bank_inbox, list_cfo_packs, list_close_checklist, list_invoices, list_job_costs, list_payments, list_receipts, match_bank_line, query_cfo_knowledge, record_collected_payment, save_job_costing, save_numbers_settings, suggest_burden_from_wages, void_receipt
app-import Import from another app high risk PaintScout export / spreadsheet import: preview_app_import (stages a preview, duplicates flagged), commit_app_import (idempotent per previewId), list_app_import_batches, undo_app_import. Writes contacts, jobs, estimates, invoices with customerNotifySuppressed — no email, SMS, drips, proposal sends, or reminders.

Tools: commit_app_import, list_app_import_batches, preview_app_import, undo_app_import

AI assistants (integrator playbook)

For ChatGPT, MCP, Grok, Cursor automations, and custom bots. Field-level JSON lives in OpenAPI — this section is behavior, scope, and safe sequences (including estimate recovery patterns).

Authentication & tenant rules

RuleDetail
Secretppops_… in Authorization: Bearer or X-Pezley-Ops-Key (env: PEZLEY_OPS_BRIEF_KEY).
EndpointPOST …/portal-ops-mrz-tool — one action per request (brief, exec, company-state, log).
orgIdTop-level JSON only; must match the org embedded in the key. Mismatch → org-mismatch. Never pass orgId inside input (stripped server-side).
Tool inputtoolName + input for exec; schema PortalOpsInput_{toolName} in OpenAPI.
Rate limit60 req/min per key → 429 rate-limited with retryMs.

ChatGPT / MCP — setup, refresh & consent

  1. Create or open the Pezley Portal developer connection in ChatGPT. For the existing Ops-key flow, use https://pezleyportal.com/mcp with OAuth. Complete the normal Portal consent screen using an administrator-issued key. Never paste a key into chat or a server URL.
  2. For an existing plugin, use Refresh tools (or Refresh in its developer connection settings), then start a new conversation with that connection enabled. Check the actual available tool names and input schemas; the catalog changes, so there is no permanent tool count.
  3. Refresh updates tool metadata. New OAuth consent is a separate step: invoke the requested action, then let the user complete the normal authentication challenge if additional access is required. Reconnect can retain the previous scope; refresh alone grants no permissions.
  4. Verify with the smallest relevant read. For Estimate Library questions, use the structured calls below; a successful morning brief does not verify library access.
RouteDiscovery and authorization
/mcpHosted MCP. Root discovery: /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. The issuer is https://pezleyportal.com; the resource is https://pezleyportal.com/mcp. Supports estimates:read, mrz, portal:control, and portal:ops:v1.
/ops-connectAlso routes MCP requests to the hosted MCP handler. Its separate discovery URLs end in /ops-connect: /.well-known/oauth-protected-resource/ops-connect and /.well-known/oauth-authorization-server/ops-connect. Both issuer and resource are https://pezleyportal.com/ops-connect. This one-click Connect flow uses Portal sign-in and selected Ops scope groups, issues a hidden named key, and excludes owner finance and app import. Follow its supplied Connect flow; do not mix its OAuth discovery with the root flow.

Anonymous initialize / tools/list expose discovery metadata, not organization data or authorization. Both routes share the current MCP handler; its tool authentication challenges point at the root resource metadata. Do not assume the alias has separate tools or rewrite authorization URLs to force a scope.

Two permission layers: every call intersects the current Ops key tools with the original OAuth allowed-tool list, for scoped and full-access keys alike. OAuth scope and the original tool list stay unchanged through refresh. For portal:ops:v1, a missing allowed-tool list fails closed. Narrowing a key takes effect immediately. Widening a key or adding tools such as Home File does not expand an existing grant; complete fresh consent to add access. Rotation or revocation invalidates grants derived from that key. The connection lifetime setting applies to new root-flow connections; existing deadlines remain unchanged.

Authentication and denial apply to the connection
If ChatGPT reports that the app needs reauthorization, pause dependent Portal calls and have the user complete the normal connection flow. This can block unrelated tools in the same app. Do not switch to batch, another wrapper, raw HTTP, another key, or manually altered OAuth URLs to get around an authentication or permission denial. If consent is declined, stop that operation and report the exact error. Resume only after access is restored through the supported flow.

Read the organization's Estimate Library explicitly

read_proposal reads one proposal and its estimate snapshot; the morning brief summarizes operations. Neither embeds the organization's full Estimate Library. A missing product or rate in either response does not mean the library is empty.

Use mrz_exec with structured toolName and input. These read calls work with mrz or portal:control OAuth access when the underlying key permits them. Product reads and analyze_rate_book require the proposals-estimates key scope; production-rate reads require production-rates (or full key access).

mrz_exec({"toolName":"list_estimate_products","input":{}})
mrz_exec({"toolName":"get_estimate_product","input":{"id":"PRODUCT_ID_FROM_LIST"}})
mrz_exec({"toolName":"list_production_rates","input":{}})
mrz_exec({"toolName":"get_production_rate","input":{"id":"RATE_ID_FROM_LIST"}})
mrz_exec({"toolName":"analyze_rate_book","input":{}})

Optional input.query filters list results. Lists return products or rates plus count; get calls return product or rate. Copy IDs from list results. Product documents can contain saved price, coverage and PDS fields; rate documents contain the saved production assumptions. analyze_rate_book returns counts, estimate settings and gap flags, not every library row. Do not invent missing prices, coverage, rates or IDs.

Batch has different consent checks: for these library reads, portal_ops_batch requires portal:ops:v1 through per-operation authorization, even though its outer metadata advertises mrz / portal:control. Likewise, the corresponding ops_ tools require Ops parity consent. A batch authorization error is not evidence of missing library data. Choose ordinary mrz_exec for this workflow from the start; after a denial, resolve consent rather than retrying through another path.

Copyable assistant prompt

Use my connected Pezley Portal to read the organization's Estimate Library. Inspect the available tool schemas first. Use mrz_exec with structured toolName/input: list_estimate_products, list_production_rates, and analyze_rate_book; use get_estimate_product/get_production_rate with IDs returned by the lists when details are needed. The library is not embedded in read_proposal or the morning brief. Report saved values and missing fields without guessing. Do not modify records. If the app requires reauthorization or denies access, stop dependent calls, show the exact error, and use the normal user consent flow; do not bypass it.

Portal jobId vs PO / estimate number

Firestore ids (e.g. JOB_ID_FROM_LIST) are not the customer-facing PO/quote printed on the PDF. Resolve jobs with list_jobs, list_sales_leads, or read_job — match on job name (PO/quote, e.g. QUOTE_NUMBER) and put the person in customerName (job naming).

The HTTP proposal schema supports estimateId or a linked jobId. The direct MCP save_proposal_draft shortcut deliberately requires estimateId. Read the proposal first and use the returned ID; this intentional schema difference alone is not evidence of a stale connector. Inspect the schema of the exact tool you are calling.

Phone leads — create_lead

Scope leads-crm (or *). Creates a Sales hub lead / to-build job — not a full estimate draft.

{
  "action": "exec",
  "orgId": "your-org-id",
  "toolName": "create_lead",
  "input": {
    "name": "Example lead",
    "phone": "CUSTOMER_PHONE",
    "customerName": "CUSTOMER_NAME",
    "address": "CUSTOMER_ADDRESS"
  }
}

Typical follow-up: book_estimate_visit (scheduling) → create_estimate + save_proposal_draft (proposals-estimates) when scope is ready to build.

Fast paths (1–2 calls for bots)

Use action: "batch" with up to 20 ordered ops[] — counts as one weighted rate-limit request. Typical estimate edit:

{
  "action": "batch",
  "orgId": "your-org",
  "atomic": true,
  "ops": [
    { "toolName": "read_proposal", "input": { "estimateId": "…", "view": "scope" } },
    { "toolName": "save_proposal_draft", "input": { "estimateId": "…", "ifVersion": 2, "customerSummary": "…" } }
  ]
}
  • Reads: default view: summary (includes writeVersion). Use scope before patching rooms; full only when needed.
  • Writes: save_proposal_draft returns updatedState (default returnView: scope) — skip a verification read_proposal when the write succeeds.
  • PDF: download_proposal_pdf returns a short-lived signed URL by default; includeBase64: true only when required. Cache warms in the background after save/send.
  • MCP: portal_ops_batch mirrors HTTP batch; tool hints mark reads vs destructive writes.

Proposal write safety (save_proposal_draft / restore_proposal_revision)

Every successful write returns writeVersion, changeSummary, and recomputed totals. HTTP responses include X-Request-Id, X-RateLimit-Remaining, and Retry-After on rate limits. Pass the same value as the Idempotency-Key header or idempotencyKey in the body for 24h idempotent retries.

ControlField / headerBehavior
Optimistic lockifVersion ← read_proposal → estimate.writeVersionOptional. Mismatch → HTTP 409, code: version-conflict, plus currentVersion. Omit for legacy callers.
IdempotencyIdempotency-Key / idempotencyKeySame key within 24h replays the first result (idempotentReplay: true) instead of double-applying.
Dry rundryRun: trueValidates patch, returns changeSummary + totals without Firestore write.
Revisionsrestore_proposal_revisionEach persist snapshots the prior draft under estimates/{id}/revisions (server-only). Restore with revisionId from that history.
Strict shapetop-level keys onlyUnknown fields → 400 unknown-field with unknownFields. Do not round-trip the full estimate object from read_proposal.

Estimate Builder (in-app) sends ifVersion on save so owner edits and ops bots cannot clobber each other; on conflict the owner sees a reload banner.

Estimates & proposals — recommended tool sequence

Scope proposals-estimates or *. Canonical loop: read_proposal → save_proposal_draft → read_proposal. Patch only fields you intend to change; confirm estimate.totals, room substrates, and line items on the second read.

○list_proposals optional → 1read_proposal { jobId } → 2create_estimate if no draft → 3save_proposal_draft delta only → 4read_proposal verify → 5Staff review & send in Portal

SENT estimate: reopen_proposal_draft → edit → read_proposal → send_proposal. See Proposals.

SENT estimate, second person: resend_proposal_email with estimateId + one email — emails the same link; nothing on the proposal changes.

SENT estimate: reopen_proposal_draft → edit → read_proposal → staff re-send in Portal. Ops keys never send proposals (customer-send-blocked, v1956). See Proposals.

Scope-loss failures can come from wiping scope with empty arrays or round-tripping compact read_proposal JSON — use targeted patches and verification reads instead.

Voice estimating (hosted MCP)

For live phone takeoff with ChatGPT + Portal Ops MCP + Plaud/Granola connectors. Goal: one tool call per spoken instruction — no long silent chains.

  1. Check context first: call read_estimate_handoff with estimateId, jobId, or jobRef. Review prior decisions, unfinished work, linked recordings, and attached note summaries. This works with the proposals-estimates scope.
  2. Call estimate_context once at the start of every conversation (jobRef, today, or YYYY-MM-DD) and keep the result. Scope: full-access key (*); narrower estimate keys use read_estimate_handoff and estimate_snapshot.
  3. Find the matching recording from recordingSearch (window = visit start minus 15 minutes through visit end plus 60 minutes, in America/Chicago and ISO) and keywords (customer last name, street, PO). Use list_recordings to browse the org’s connected Plaud/Granola account and read_recording_transcript to read the speech (follow nextOffset until null). A summary is separate from a transcript; transcriptAvailable:false means speech is unavailable. Then link_recording with estimateId or jobId/jobRef (proposals-estimates or jobs-files). Personal recording accounts are not browsed by these tools.
  4. Make one update_estimate call per spoken instruction (preferred over read_proposal → save_proposal_draft → read_proposal). Example: “add a 12 by 14 bedroom, walls and ceiling, two coats.”
  5. Do not re-read the full proposal after writes — update_estimate returns compact rooms/lines/colors and recomputed totals.
  6. Give a short spoken acknowledgement before slow calls.
  7. Read back only totals and changed rows.
  8. Ask the owner only when truly ambiguous: two visits in the same window, or no recording found.
  9. Leave context after work: call add_estimate_note with what changed, key decisions/assumptions, open questions, and next steps. Keep each note under 1,000 characters. Reuse the same idempotencyKey only when retrying that note. The shared Office handoff allows 4,000 characters total and preserves existing notes; if full, ask staff to condense it.

Internal only: handoff notes appear to the estimator in Office handoff. They are stored separately from estimates and never included in client proposals, PDFs, share pages, or customer AI. Never paste internal context into customer-facing estimate notes. These tools require proposals-estimates (or full access). Existing MCP connections must complete fresh consent to gain newly added tools.

{"action":"exec","orgId":"YOUR_ORG_ID","toolName":"read_estimate_handoff","input":{"jobId":"job-123"}}
{"action":"exec","orgId":"YOUR_ORG_ID","toolName":"add_estimate_note","input":{"jobId":"job-123","note":"Updated bedroom walls and ceiling. Confirm trim sheen before sending.","idempotencyKey":"bedroom-handoff-1"}}

update_estimate resolves natural jobRef server-side (PO, customer, address, jobId). Ambiguous refs return a short candidate list — never guess. Merge-only: empty arrays are no-ops; explicit removeAreaIds / removeDescriptorIds / removeColorIds only. estimate_snapshot is the lean read-back tool when needed.

create_estimate vs save_proposal_draft

ToolRoleMerge semantics
create_estimate Mint a blank draft on an existing job (jobId required). Does not create jobs, rooms, or send email. If the job already has a draft estimate, returns that draft (reused: true). Non-draft linked estimate → estimate-already-linked. Stale job.estimateId pointing at a missing doc is relinked instead of minting a second blank when the handler can recover.
save_proposal_draft Patch an existing draft (needs estimateId or resolvable jobId). Firestore set(..., { merge: true }) with guarded payloads: partial top-level fields; areas[] merged by room id; lineItems / colors merged by row id unless replaceLineItems / replaceColors. Server ignores client totals and recalculates from scope. OpenAPI: PortalOpsInput_save_proposal_draft.

What to include on save_proposal_draft

IntentFields (include only what changes)
CopycustomerSummary, notes, crewNote, exclusions (exclusions shape)
Structured paint colorscolors merge by id; removeColorIds, replaceColors, clearColors — see estimate colors
Line items / optionslineItems merge by id; replaceLineItems:true. Flat amount: see the pricing rule
Remove line itemsremoveLineItemIds (line id or descriptorId) — draft only, idempotent; returns removedLineItemIds / lineItemIdsNotFound
Change one room or linepatchArea / patchLineItem: { id, ...fields to change }. An unknown room or line id is an error and saves nothing. A substrate id that is missing or not already on that room is substrate-not-found and saves nothing — add a new surface through areas
PricinghourlyRate, taxRate, discount, wastePercent
Base descriptorsdescriptorIds / selectedDescriptors / descriptors (merge-add catalog rows)
Detach descriptorsremoveDescriptorIds / detachDescriptorIds (catalog descriptorId or line id) — draft only; returns removedDescriptorIds / descriptorIdsNotFound
RoomsOne areas[] object per room with stable id + changed fields (L×W×H when adding rooms)
Remove roomsremoveAreaIds — not areas: []

Estimate pricing rule

PRICING RULE: price painting and staining through areas[] (substrates with productId, productionRateId, coats) so the crew work order gets hours and materials. Use a flat-priced lineItem only for an add-on such as lift or equipment rental (set flatAddOn:true). Never add a separate paint/stain materials line; materials come from the products on area substrates.

GOOD: areas:[{name:"Living room",length:18,width:15,height:9,substrates:[{name:"Walls",productionRateId,productId,coats:2}]}] plus lineItems:[{name:"Boom lift rental",amount:1200,flatAddOn:true}]. BAD: {name:"Exterior painting",amount:4800} (painting as a flat price, so no crew hours); {name:"Paint and materials",amount:650} or {name:"Duration (5 gal)",amount:450} (separate materials line).

Guidance only: the server does not reject lines for their price.

CodeMeaning
descriptor-scope-requiredupsert_estimate_descriptor was called with an estimate, proposal, job or customer key, or with hours, dimensions, substrates, or rate/product ids. Nothing is written. It edits the shared library only: resend without those keys (scope:"library" does not override them), or use save_proposal_draft for one estimate. price and flatAddOn may be stored for an add-on such as a lift rental.
substrate-not-foundpatchArea named a substrate id that is missing or not on that room. Nothing is saved. Add the surface through areas.
unknown-fieldResponse includes allowedFields — the keys the tool accepts.

edit_job with hoursEstimate updates the job stamp only — not Estimate Builder takeoff hours. Use save_proposal_draft for builder scope.

Anti-patterns (wiped hours / lines)

Do notWhy
areas: [] or lineItems: []Probe payloads; server strips dangerous empties — agents must not send them.
Room patch with substrates: []Clears all scope lines on that room unless you intentionally clear with clearSubstrates: true; empty substrates are ignored when existing lines remain.
replaceDescriptors: true with no idsUsed to wipe descriptors; treated as no-op when it would delete everything.
Save full read_proposal JSON backCompact read rows omit builder fields — patch deltas only.
create_estimate when job already has scopeReuses draft when linked; do not use as a “reset”. Prefer read_proposal + targeted patches.
totals-only save_proposal_draftDoes not rebuild missing scope; fix rooms/descriptors first, then verify totals on re-read.
Partial draft without colorsSafe — omit colors to leave existing rows untouched.
colors: [] aloneNo-op (probe payloads stripped). Use clearColors:true or removeColorIds to delete rows.
Trimmed colors without replaceColorsSafe — merges by id; rows you omit are kept. Use replaceColors:true only when rewriting the whole section.
Client-sent totalsIgnored — totals are always recomputed from scope after save.
Inventing colors not in estimate textBuilder “Suggest from scope” only extracts mentions (SW/BM codes, known names). Ops/API rows should set source: "ops" when adding manually.

Structured estimate colors (estimate.colors[])

Same field on the Firestore estimate as Estimate Builder → Colors (optional, collapsible). Not the customer share job.colorPlan picker — that stays on set_job_colors after deposit.

ReadWhere
read_proposalestimate.colors — rows with areaId, areaName, optional scope, brand, code, name, display, notes, source, optional citation
read_jobjob.estimateColors — same shape when job.estimateId is set

Write: save_proposal_draft with colors rows (each has a stable id from read_proposal or derived from area+scope+code). Default is merge by id — upsert the rows you send, keep the rest. Remove with removeColorIds; full replace with replaceColors:true; clear all with clearColors:true. colors: [] alone is a no-op. Omit colors on unrelated patches.

source: manual (estimator in builder), ai-extract (text scan — include citation when present), ops (API/automation). Estimate Builder saves the full in-app list with ids — same merge rules apply when rows include stable ids.

Example: { "estimateId": "…", "colors": [{ "areaId": "living-room", "scope": "Walls", "code": "SW 7029", "name": "Agreeable Gray", "display": "Agreeable Gray (SW 7029)", "source": "ops" }] }

High-risk scope groups

Settings asks for extra confirmation when enabling these on a new key. Issue separate keys per automation when possible.

ScopeRiskExamples
owner-financeOwner $ — margin, burden, CFO, bank, job costinganalyze_numbers, save_job_costing
proposals-estimatesCustomer-facing proposals & sendsend_proposal, save_proposal_draft
jobs-filesJob CRUD, uploads, delete filesdelete_job_file, upload_job_file
crew-clockPayroll-affecting clock & shiftsclock_in_user, edit_shift
app-importBulk contacts, jobs, estimates, invoices — Import from another appcommit_app_import, undo_app_import

Medium-risk groups (action-feed, job-delays, job-status, scheduling, company-state) still mutate production data — scope narrowly.

Common error code values

HTTP layer returns { error, code, message?, hint?, toolName?, allowed? }. message says what happened. hint is one sentence the assistant can act on. Full enum: OpenAPI PortalOpsError. Tool-level failures may be 400 with domain codes (e.g. estimate-not-found).

CodeTypical causeFix
ops-brief-scope-deniedTool not in any enabled scope groupEnable the group in Settings or pick a different tool; check allowed in the response.
briefing-scope-requiredbrief / company-state without briefing-readAdd briefing-read to the key.
finance-scope-requiredNumbers / CFO tool without owner-financeAdd owner finance scope or use a non-finance tool.
numbers-cap-requiredFinance read/write when the signed-in user lacks Numbers cap (in-app admin chat path; rare on pure ppops_ flows)Org admin with Numbers access, or adjust automation to non-finance tools.
send-blockedshare_proposal / publish_proposal aliasesSave the draft; staff send from Portal.
customer-send-blockedsend_proposal on an ops key (v1956)Assistant keys never email or text customers. Save the draft and ask staff to send it.
money-write-blockedsave_job_costing, save_numbers_settings, bank match / reconcile on an ops keyRead with owner-finance data reads; staff post money changes.
money-field-blockedContract amount, price, cost, rate, deposit %, tax on add_job / pricing-config writesRetry without the fields listed in unknownFields; staff set money values.
customer-confirmation-requiredbook_estimate_visit / book_client_visit without customerConfirmed: trueConfirm the time with the customer first, then retry.
drip-enable-blockedupdate_drip_sequence with enabled: trueEdit the sequence; staff turn it on.
bad-cursorData API cursor from another tool or filter setRestart paging without cursor.
create-estimate-blockedpersist_takeoff / save_takeoffcreate_estimate + save_proposal_draft.
ops-mcp-consent-staleScoped or full-access key allows an action outside this Ops grant's original consent snapshotObtain new portal:ops:v1 consent through normal Connect. Refresh tools alone cannot repair consent.
insufficient_scopeOAuth grant lacks the requested action scopeFollow the normal mcp/www_authenticate challenge. Library reads inside batch request portal:ops:v1. Do not bypass the denial.
invalid_token / Reconnect Pezley Portal to continue.Missing or invalid connection token, or changed session identityPause dependent calls and reauthorize the app normally.
invalid_grantOAuth exchange/refresh grant is invalid, expired, revoked or replayedStart a fresh supported Connect flow; a tool-metadata refresh is separate.
New Ops consent is required. / ops-consent-requiredOps grant lacks its allowed-tool listComplete new explicit Ops consent; never manufacture a grant.
estimateId or jobId requiredPDF/reopen call has no targetResolve the proposal ID first. For a direct draft shortcut schema error, supply its required estimateId.
org-mismatchBody orgId ≠ key orgPass the correct top-level orgId.
rate-limited> 60 req/min on this keyBackoff using retryMs.
scopes-emptygenerate-key with no scopesPass explicit scope ids or ["*"].
unknown-fieldBody used args or another undocumented top-level keyMove tool arguments to input. Remove the unknown field named in message.
job-not-foundNo job for the id that was triedmessage includes the identifier. Call list_jobs and retry with a jobId from that list.

Blocked uploads: vendor-invoice-key-required, bank-statement-key-required — see blocked tools. Import codes (import-file-too-large, preview-expired, …): Import from another app.

Scheduling (scope scheduling)

Keys with Scheduling & visits get the same server tools the in-app schedule assistant uses for bars, spots, crew assignment, visits, delays, and shift reads — not a read-only subset. Issue scheduling alone for a schedule bot; add production-rates if the assistant needs rate-book capacity hints.

Estimates & exclusions

Estimate drafts use create_estimate (blank draft on an existing job) and save_proposal_draft (rooms, descriptors, pricing, exclusions). Scope proposals-estimates or *. Use the job's Portal jobId, not the printed estimate number. Automations: see AI assistants before editing drafts.

Exclusions shape (required pattern)

Exclusions are what is NOT included — never stuff them into descriptor description or room notes. OpenAPI documents exclusions as:

{
  "action": "exec",
  "orgId": "your-org-id",
  "toolName": "save_proposal_draft",
  "input": {
    "estimateId": "ESTIMATE_ID",
    "exclusions": [
      "Soffits and fascia are excluded.",
      { "text": "Moisture repairs behind siding are excluded." }
    ],
    "customerSummary": "Prep and paint main body per scope."
  }
}

Rooms need length, width, and height (or sqft) to persist. Per-room notes: "" clears; omit to keep. Patch rules: AI assistants.

Proposals

Typical Hub flow (scope proposals-estimates or *). New blank draft: create_estimate first; existing job: start at read_proposal. See AI assistants.

1read_proposal → 2save_proposal_draft → 3read_proposal verify → 4Staff send in Portal

Jobs, delays & limbo

Jobs & files (jobs-files): create_job / add_job, edit_job, uploads, work orders, rename, hours, tasks. Use PO/quote as name — see job naming.

Delays (job-delays): post_job_delay with customer-visible copy; optional polish_delay_reason to tighten wording. Same semantics as crew check-in Delay.

Status & limbo (job-status): set_job_status for active, accepted, limbo, completed, archived; polish_limbo_reason for staff-facing limbo notes; flags for general / T&M jobs per OpenAPI schema.

{
  "action": "exec",
  "orgId": "your-org-id",
  "toolName": "post_job_delay",
  "input": {
    "jobId": "JOB_ID",
    "delayKind": "rain",
    "reason": "Storms — crew rescheduled to Thursday."
  }
}

Staff Home File

The home-file scope (or Full access) grants read_home_file, set_home_file_access, set_home_file_formulas and set_home_file_leftovers. Jobs & files and morning brief scopes do not grant these tools. Gate codes, lockbox details, access notes and pets remain staff-only; these tools do not send or publish a customer Home File.

For formulas or leftovers, pass jobId and the matching row list. A matching row id replaces that row; a new row appends. removeIds removes selected rows, and replace: true replaces the whole list. Rows and parent metadata commit together. Concurrent edits to separate rows survive, and a failed transaction commits nothing. Each job save keeps the existing cap of 60 formulas or 40 leftovers; the response reports dropped overflow rows.

Use items[] for up to 20 jobs. Each job is a separate transaction: a later failure does not undo a job already saved. Prices are stripped from the timeline and row text. Use the supported consent flow if the requested tool is denied; changing a key or refreshing metadata does not upgrade an OAuth scope.

Owner finance scope

Margin, burden, CFO packs, bank inbox, Numbers settings, and job costing writes require the owner-finance scope on the ops key (or a legacy full-access key). Without it, finance tools return 403 with finance-scope-required.

Tools include analyze_numbers, advise_cfo, query_cfo_knowledge, list_cfo_packs, draft_cfo_report, save_numbers_settings, save_job_costing, bank reconciliation helpers, and related reads — full list under owner-finance in the scope table above and in OpenAPI x-portal-ops-scope-appendix.

High risk
Issue finance scope only to automations you trust with owner-level dollars. Crew-facing keys should omit owner-finance.

Import from another app

The same importer as Settings → Import from another app, on the Portal Ops key. It reads a PaintScout export (the .tar.gz, or its loose files) or the spreadsheet template CSV, matches it against this org's jobs, contacts, estimates, and invoices, and imports the result as one batch you can undo. DripJobs is coming soon.

Who can call it: keys with the app-import scope (high risk) or Full access, and org-admin ID tokens. One-click Connect (ChatGPT / Claude) cannot grant app-import — issue a manual key. Every write lands in the key's own org; orgId inside input is ignored and a top-level orgId for another org returns org-mismatch.

How to call it: the normal exec (one tool) or batch (ops[]) on portal-ops-mrz-tool. On hosted MCP use mrz_exec with toolName (or portal_ops_batch). There is no separate import endpoint and no ops_ shortcut for these four tools.

ToolKindWhat it does
preview_app_importwriteStages a preview (a write). Does not write contacts, jobs, estimates, or invoices. Input source (paintscout | generic), mode (unresolved-jobs default | sales-history), and one upload: tarGzBase64, files ({ path: text }), or csv. Optional quoteNumbers, recordIds, query, status, maxRows. Returns previewId, complete counts, duplicates, and sample rows / actions. The staged preview lasts 24 hours. Committing it keeps edits made to a matched job after the preview, including customerName.
commit_app_importwriteInput previewId. Writes the staged preview and returns importBatchId with written / created / updated.
list_app_import_batchesreadRecent batches (Settings and Portal Ops): importBatchId, status (committed | partial | undone), source, mode, counts, via. Optional limit (default 20, max 50).
undo_app_importwriteInput importBatchId. Deletes records the batch created. On records it updated, puts back only the fields the import changed, re-read inside a transaction so a payment or punch in that gap is not overwritten. Hours logged after the import (hoursLogged, actualHours) and any other edit made after the import stay. A job with logged hours is left in place (not deleted) and is not set back to estimate (conflicts, reason: "has-hours"). A created invoice whose paidCents, payments, or paidAt differs from what the import wrote is left in place, and so are its job, contact, and estimate (reason: "has-payment"). An updated invoice in that state is left unchanged, including amountCents and contractTotalCents. That job's status stays too, including when the import had moved it off estimate. Changing only the invoice status does not keep the billed amount or the job status, and it is not reported as collected. The undo snapshot stores the invoice paidAt the commit wrote. A money field missing from that snapshot is not treated as a new payment, so a paidAt that was already on the invoice does not keep the billed amount or the previous payment. An invoice this import updated onto a created job is part of the batch, so undo does not call it an extra invoice. With no later payment, that job, its estimate, and its created invoices are removed and the updated invoice is restored. A later payment on that invoice keeps them, and the conflict op is restore. A refund that lowers the amount paid is described as a refund. An extra invoice on a created job says it was added, or that it was paid, matching that invoice. A job whose paidAt differs from the import is left in place too. Contact deletes are sized so the contact read, the jobs query, and the delete commit together. If that step stops after the jobs are already gone, undo resumes and finishes the contacts. Before those deletes, the same transaction reads the job and its shifts again. A punch that lands after the first hours check leaves that job, contact, estimate, and invoice in place (reason: "has-hours"). restored counts records undo actually wrote. A contact shared by several jobs is deleted only after every one of those jobs has been checked, so a payment on a later job keeps the contact. A new invoice on the job, including a deposit, keeps the job and its invoices. A contact that another job outside this batch still uses is left in place. Changing only a payment row does not roll paidCents or status back. Safe to repeat (alreadyUndone). The appImportPreviews rules deny is deploy-later and is not published from this branch.
{ "action": "batch", "orgId": "your-org-id", "ops": [
  { "toolName": "preview_app_import", "input": { "source": "generic", "csv": "quoteNumber,firstName,…" } }
] }
// review counts + duplicates, then:
{ "action": "exec", "orgId": "your-org-id", "toolName": "commit_app_import", "input": { "previewId": "aip_…" } }

Duplicates: jobs match on the quote number only — never an invoice number, because the two sequences collide. Contacts match on PaintScout contact id, email, or 10-digit phone; estimates and invoices on their PaintScout ids. Each match is listed in duplicates with reason: "already-in-portal" and is updated, not created. Imported ids are stable, so importing the same quote again updates the same records.

Idempotent commit: previewId comes from the org, source, mode, file bytes, and selection. Calling commit_app_import again returns the same importBatchId with alreadyCommitted: true and writes nothing. A commit that stopped partway resumes the same batch: fields edited after the rows it already wrote stay, including a payment's method, check number, and client payment id. The saved undo snapshot is not replaced. Each contact, estimate, job, and invoice is written in its own transaction, so an edit made after the prepare read is not overwritten. A payment or refund that lands after that invoice was read stays, and so do the imported amount, status, and label. The undo snapshot stores what the import meant to write, not that live payment or refund. A job paidAt set in that gap stays on the job and is not stored as the import's paid date. Those payment conflicts are stored on the batch and show up from list_app_import_batches. The commit renews its claim while it reads and writes. It marks the preview committed only while that preview still says committing. If undo wins first, the commit stops and does not set committed. Previewing the same file and selection after a commit returns status: "committed". To import it again, undo that batch first, then preview again.

Limits: upload 4 MB (decoded tar.gz, or UTF-8 bytes of csv / files); a tar.gz may unpack to 32 MB; at most 200 files. A hosted MCP request is capped at 5.5 MB, and base64 adds a third, so send uploads near 4 MB over HTTP exec. preview_app_import, commit_app_import, and undo_app_import each add 5 to the rate-limit weight, on exec or inside batch, so one of them is not weight 1.

Customers are never contacted
Every imported contact, job, estimate, and invoice carries customerNotifySuppressed, the plan's sideEffects is always empty, and contacts get marketingConsent: false. Import sends no email or text, starts no drip or seasonal nurture, sends no proposal, and schedules no reminder or follow-up. Upload contents are not written to the Portal Ops activity log.
CodeMeaning
import-file-too-large (413)Upload over 4 MB.
import-unpacked-too-large (413)tar.gz unpacks past 32 MB.
import-input-required / import-too-many-files / import-unreadableMissing, oversized file list, or unreadable upload.
bad-source (400)source must be paintscout or generic.
bad-mode (400)mode must be unresolved-jobs or sales-history.
coming-soon (400)source: "dripjobs" is recognized and not available yet.
bad-plan (400)The file was read but a plan could not be built.
bad-payload (400)A staged record failed validation. Run preview_app_import again.
preview-not-found / preview-expired / preview-not-ready / preview-incompleteRun preview_app_import again.
commit-in-progress (409)Another commit for this preview is running. Retry in a few minutes. Undo returns this too until that commit finishes, from Portal Ops or Settings.
batch-undone (409) / batch-not-found (404)The batch was undone, or is not in this org. A commit that notices undo mid-run returns batch-undone and writes nothing further.
commit-lost (409)The preview is no longer committing, so the commit stopped.

Job naming tip

PO / quote number = job title

Use the PaintScout PO or quote number as the job name (e.g. QUOTE_NUMBER). Put the person's name in customerName and address, phone, and email in contact fields — not as the job title. This keeps dispatch, crew, and integrations aligned.

Business data, photos & webhooks (v2127)

Read-only, org-scoped data tools for assistants. Every call goes through exec / batch on the same endpoint and the same rate limit (60 requests / minute per key). Scope business-data covers everything except money records; invoices, payments and job costing also need owner-finance.

ResourceTools
Leads (full record, attribution, form answers, status history)list_leads, get_lead
Customers + historylist_contacts, get_contact
Proposals / estimates (drafts, line items, prices, viewed / signed events)list_estimates, get_estimate
Invoices & payments (Stripe + offline) — owner-financelist_invoices, get_invoice, list_payments
Estimator calendars, open slots, booked visitslist_schedule
Jobs / production, crew, dates, check-inslist_production, get_job_production
Photos with who / when / room / caption / approvallist_photos
Every customer email + text with delivery statuslist_messages
Follow-ups: drip enrollments, queued sends, texting consentlist_followups
Staff roster, roles, titles, permissionslist_staff
Shifts, clock-ins, PTOlist_hours
Job costing: supplier invoices, materials — owner-financelist_job_costs
Colors & products per joblist_job_colors
Reviews & referralslist_reviews_referrals
Settings (read-only): contract types, deposit %, rates, catalogget_settings
Activity / audit loglist_activity

Paging & filters (all list tools): limit (max 200), cursor (pass back nextCursor), sinceMs / untilMs (or ISO since / until), jobId, contactId, customer (name / email / phone text), and status where it applies. Secrets (key hashes, API keys, push tokens) are never returned. Pay rates, margin and burden are removed unless the key has owner-finance.

{
  "action": "exec",
  "orgId": "your-org-id",
  "toolName": "list_messages",
  "input": { "contactId": "CONTACT_ID", "sinceMs": 1760000000000, "limit": 50 }
}

Photos (scope photos)

upload_photo attaches a base64 image (JPEG, PNG, WebP, GIF, HEIC; 12 MB max) to a job or estimate (areaId / substrateId place it on the proposal once approved). save_annotated_photo saves an AI markup as a new linked photo (parentPhotoId / parentStoragePath); the original is never changed. Every assistant photo starts approval.status: "pending" and is hidden from the customer share page, proposal, PDF, portfolio and share Ask until a staff member approves that photo in Portal. Approval uses the existing admin, estimator or office photo permission; only an active admin can remove approval. Private bytes stay token-free; approval creates a fresh immutable public copy for that job. Link an unattached estimate to a job before approval. download_photo returns a short-lived staff URL and optional base64. list_pending_share_photos lists pending records. annotate_photo uses the same new-linked-photo writer as save_annotated_photo. Existing MCP grants need fresh consent to gain the new tools.

Webhooks (scope webhooks)

create_webhook with a public https URL and events from lead.created, customer.replied, proposal.viewed, proposal.signed, payment.received. The signing secret is returned once. Each POST carries X-Pezley-Event, X-Pezley-Delivery and X-Pezley-Signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + rawBody). Bodies carry record ids only (leadId, jobId, contactId, estimateId, invoiceId, …) — read details with the data tools. Failed deliveries retry with backoff (up to 6 attempts). list_webhooks / delete_webhook manage subscriptions (max 10 per org).

What keys cannot do

Operator responsibility

Your organization controls who receives a Portal Ops key and which scopes each key has. Anyone holding the secret can perform every action allowed by those scopes for your org.

Pezley is not responsible for how external assistants, automations, or third parties use a key, or for data they read or write through it. Issuing a key means you accept that risk for your organization.

Audit trail: Settings → Audit Log labels Portal Ops traffic as Portal Ops call.

Need help in the app?

Org admins manage keys under Settings → Portal Ops keys at pezleyportal.com.

Pezley Portal · Portal Ops guide · v2091 · Updated October 19, 2026