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.
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.
https://pezleyportal.com/ops-connect. OAuth client ID is pezley-chatgpt. Leave the client secret blank.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.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.
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.
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
exec tool has a PortalOpsInput_* schema, scope tags, and write tools include x-examples.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.PEZLEY_OPS_BRIEF_KEY). Never commit it to git or paste it into customer-visible channels.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.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.403 / 400, read the code field and adjust scopes or tool choice — see error codes, scope groups, and blocked tools.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.
?adminTab=opsbrief).["*"] (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.ppops_… value once into the assistant's secret store (often PEZLEY_OPS_BRIEF_KEY).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
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 }
| Item | Value |
|---|---|
| Environment variable | PEZLEY_OPS_BRIEF_KEY (or a named secret per assistant) |
| Authorization header | Authorization: Bearer <your ppops key> |
| Alternate header | X-Pezley-Ops-Key: <your ppops key> |
| HTTP endpoint | POST https://pezleyportal.com/.netlify/functions/portal-ops-mrz-tool (also OPTIONS for CORS) |
| Legacy alias | POST …/openclaw-mrz-tool — deprecated thin re-export; migrate to portal-ops-mrz-tool. |
| Key management | POST 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 body | orgId must match the org embedded in the key (top-level JSON field, not inside input). Mismatch → 403 org-mismatch. |
| Rate limit | Ops 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. |
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.
briefMorning operations snapshot. Requires briefing-read on ops keys. Optional sessionId, adminName for audit.
{
"action": "brief",
"orgId": "your-org-id"
}
execOne 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-stateCompact 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"
}
logAppend 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"
}
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.
asOftodayYYYY-MM-DD in the org timezone.orgIdtodayJobs / weekJobslist_jobs / dispatch).onClockhourscrewsalesLeadsleads-crm or briefing reads allow.proposalspipelinependingCheckinsaction-feed is enabled.learningAiWhen 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.
| Scope id | Label | What 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 |
| Rule | Detail |
|---|---|
| Secret | ppops_… in Authorization: Bearer or X-Pezley-Ops-Key (env: PEZLEY_OPS_BRIEF_KEY). |
| Endpoint | POST …/portal-ops-mrz-tool — one action per request (brief, exec, company-state, log). |
orgId | Top-level JSON only; must match the org embedded in the key. Mismatch → org-mismatch. Never pass orgId inside input (stripped server-side). |
| Tool input | toolName + input for exec; schema PortalOpsInput_{toolName} in OpenAPI. |
| Rate limit | 60 req/min per key → 429 rate-limited with retryMs. |
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.| Route | Discovery and authorization |
|---|---|
/mcp | Hosted 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-connect | Also 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.
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.
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.
jobId vs PO / estimate numberFirestore 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.
create_leadScope 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"
}
}
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": "…" } }
]
}
view: summary (includes writeVersion). Use scope before patching rooms; full only when needed.save_proposal_draft returns updatedState (default returnView: scope) — skip a verification read_proposal when the write succeeds.download_proposal_pdf returns a short-lived signed URL by default; includeBase64: true only when required. Cache warms in the background after save/send.portal_ops_batch mirrors HTTP batch; tool hints mark reads vs destructive writes.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.
| Control | Field / header | Behavior |
|---|---|---|
| Optimistic lock | ifVersion ← read_proposal → estimate.writeVersion | Optional. Mismatch → HTTP 409, code: version-conflict, plus currentVersion. Omit for legacy callers. |
| Idempotency | Idempotency-Key / idempotencyKey | Same key within 24h replays the first result (idempotentReplay: true) instead of double-applying. |
| Dry run | dryRun: true | Validates patch, returns changeSummary + totals without Firestore write. |
| Revisions | restore_proposal_revision | Each persist snapshots the prior draft under estimates/{id}/revisions (server-only). Restore with revisionId from that history. |
| Strict shape | top-level keys only | Unknown fields → 400 unknown-field with unknownFields. Do not round-trip the full estimate object from read_proposal. |
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
→
1read_proposal { jobId }
→
2create_estimate
→
3save_proposal_draft
→
4read_proposal
→
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.
For live phone takeoff with ChatGPT + Portal Ops MCP + Plaud/Granola connectors. Goal: one tool call per spoken instruction — no long silent chains.
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.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.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.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.”update_estimate returns compact rooms/lines/colors and recomputed totals.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"}}
create_estimate vs save_proposal_draft| Tool | Role | Merge 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. |
save_proposal_draft| Intent | Fields (include only what changes) |
|---|---|
| Copy | customerSummary, notes, crewNote, exclusions (exclusions shape) |
| Structured paint colors | colors merge by id; removeColorIds, replaceColors, clearColors — see estimate colors |
| Line items / options | lineItems merge by id; replaceLineItems:true. Flat amount: see the pricing rule |
| Remove line items | removeLineItemIds (line id or descriptorId) — draft only, idempotent; returns removedLineItemIds / lineItemIdsNotFound |
| Change one room or line | patchArea / 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 |
| Pricing | hourlyRate, taxRate, discount, wastePercent |
| Base descriptors | descriptorIds / selectedDescriptors / descriptors (merge-add catalog rows) |
| Detach descriptors | removeDescriptorIds / detachDescriptorIds (catalog descriptorId or line id) — draft only; returns removedDescriptorIds / descriptorIdsNotFound |
| Rooms | One areas[] object per room with stable id + changed fields (L×W×H when adding rooms) |
| Remove rooms | removeAreaIds — not areas: [] |
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).
| Code | Meaning |
|---|---|
descriptor-scope-required | upsert_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-found | patchArea named a substrate id that is missing or not on that room. Nothing is saved. Add the surface through areas. |
unknown-field | Response includes allowedFields — the keys the tool accepts. |
| Do not | Why |
|---|---|
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 ids | Used to wipe descriptors; treated as no-op when it would delete everything. |
Save full read_proposal JSON back | Compact read rows omit builder fields — patch deltas only. |
create_estimate when job already has scope | Reuses draft when linked; do not use as a “reset”. Prefer read_proposal + targeted patches. |
totals-only save_proposal_draft | Does not rebuild missing scope; fix rooms/descriptors first, then verify totals on re-read. |
Partial draft without colors | Safe — omit colors to leave existing rows untouched. |
colors: [] alone | No-op (probe payloads stripped). Use clearColors:true or removeColorIds to delete rows. |
Trimmed colors without replaceColors | Safe — merges by id; rows you omit are kept. Use replaceColors:true only when rewriting the whole section. |
Client-sent totals | Ignored — totals are always recomputed from scope after save. |
| Inventing colors not in estimate text | Builder “Suggest from scope” only extracts mentions (SW/BM codes, known names). Ops/API rows should set source: "ops" when adding manually. |
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.
| Read | Where |
|---|---|
read_proposal | estimate.colors — rows with areaId, areaName, optional scope, brand, code, name, display, notes, source, optional citation |
read_job | job.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.
Settings asks for extra confirmation when enabling these on a new key. Issue separate keys per automation when possible.
| Scope | Risk | Examples |
|---|---|---|
owner-finance | Owner $ — margin, burden, CFO, bank, job costing | analyze_numbers, save_job_costing |
proposals-estimates | Customer-facing proposals & send | send_proposal, save_proposal_draft |
jobs-files | Job CRUD, uploads, delete files | delete_job_file, upload_job_file |
crew-clock | Payroll-affecting clock & shifts | clock_in_user, edit_shift |
app-import | Bulk contacts, jobs, estimates, invoices — Import from another app | commit_app_import, undo_app_import |
code valuesHTTP 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).
| Code | Typical cause | Fix |
|---|---|---|
ops-brief-scope-denied | Tool not in any enabled scope group | Enable the group in Settings or pick a different tool; check allowed in the response. |
briefing-scope-required | brief / company-state without briefing-read | Add briefing-read to the key. |
finance-scope-required | Numbers / CFO tool without owner-finance | Add owner finance scope or use a non-finance tool. |
numbers-cap-required | Finance 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-blocked | share_proposal / publish_proposal aliases | Save the draft; staff send from Portal. |
customer-send-blocked | send_proposal on an ops key (v1956) | Assistant keys never email or text customers. Save the draft and ask staff to send it. |
money-write-blocked | save_job_costing, save_numbers_settings, bank match / reconcile on an ops key | Read with owner-finance data reads; staff post money changes. |
money-field-blocked | Contract amount, price, cost, rate, deposit %, tax on add_job / pricing-config writes | Retry without the fields listed in unknownFields; staff set money values. |
customer-confirmation-required | book_estimate_visit / book_client_visit without customerConfirmed: true | Confirm the time with the customer first, then retry. |
drip-enable-blocked | update_drip_sequence with enabled: true | Edit the sequence; staff turn it on. |
bad-cursor | Data API cursor from another tool or filter set | Restart paging without cursor. |
create-estimate-blocked | persist_takeoff / save_takeoff | create_estimate + save_proposal_draft. |
ops-mcp-consent-stale | Scoped or full-access key allows an action outside this Ops grant's original consent snapshot | Obtain new portal:ops:v1 consent through normal Connect. Refresh tools alone cannot repair consent. |
insufficient_scope | OAuth grant lacks the requested action scope | Follow 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 identity | Pause dependent calls and reauthorize the app normally. |
invalid_grant | OAuth exchange/refresh grant is invalid, expired, revoked or replayed | Start a fresh supported Connect flow; a tool-metadata refresh is separate. |
New Ops consent is required. / ops-consent-required | Ops grant lacks its allowed-tool list | Complete new explicit Ops consent; never manufacture a grant. |
estimateId or jobId required | PDF/reopen call has no target | Resolve the proposal ID first. For a direct draft shortcut schema error, supply its required estimateId. |
org-mismatch | Body orgId ≠ key org | Pass the correct top-level orgId. |
rate-limited | > 60 req/min on this key | Backoff using retryMs. |
scopes-empty | generate-key with no scopes | Pass explicit scope ids or ["*"]. |
unknown-field | Body used args or another undocumented top-level key | Move tool arguments to input. Remove the unknown field named in message. |
job-not-found | No job for the id that was tried | message includes the identifier. Call list_jobs and retry with a jobId from that list. |
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.
list_jobs (schedule + spots), read_job, list_crew, list_appointments, scan_shifts / analyze_time_trends for capacity.edit_job_schedule (startDate, endDate, estimatedHours, crewLeadName, notBefore, datesLocked). Shift the end date when you push the start unless you intend to shorten the job.add_spot_assignment / remove_spot_assignment — day-level overlay; does not move the schedule bar.assign_to_job; edit_job with hoursEstimate when planning length (not for arbitrary status edits — use job-status).list_schedule (estimator hours, booked visits, open slots), then book_estimate_visit (booking again moves it), offer_estimate_visit, cancel_estimate_visit, book_client_visit, cancel_client_visit. Book only after the customer confirmed: pass customerConfirmed: true. Booking never emails or texts the customer, and automatic visit reminders stay off unless you pass notifyCustomer: true because you were explicitly asked.post_job_delay (+ optional polish_delay_reason) — customer share + crew cascade.write_schedule_with_ai opens the Write with AI panel in the portal; automations should use edit_job_schedule or visit tools instead.ops-mcp-consent-stale). Clock in/out and Owner $ tools stay off the hosted catalog — not required for scheduling.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 are what is NOT included — never stuff them into descriptor description or room notes. OpenAPI documents exclusions as:
{ "text": "…" } (optional id).{
"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."
}
}
Typical Hub flow (scope proposals-estimates or *). New blank draft: create_estimate first; existing job: start at read_proposal. See AI assistants.
read_proposal
→
2save_proposal_draft
→
3read_proposal
→
4Staff send in Portal
download_proposal_pdfincludeBase64: false if you only need metadata.reopen_proposal_draftresend_proposal_emailshare_proposal / publish_proposalsend_proposal to email the customer on file.send_proposal, share_proposal, and publish_proposal are blocked on ops keys (v1956). Assistants save drafts; staff send from Portal.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."
}
}
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.
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.
owner-finance.
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.
| Tool | Kind | What it does |
|---|---|---|
preview_app_import | write | Stages 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_import | write | Input previewId. Writes the staged preview and returns importBatchId with written / created / updated. |
list_app_import_batches | read | Recent batches (Settings and Portal Ops): importBatchId, status (committed | partial | undone), source, mode, counts, via. Optional limit (default 20, max 50). |
undo_app_import | write | Input 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.
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.
| Code | Meaning |
|---|---|
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-unreadable | Missing, 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-incomplete | Run 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. |
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.
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.
| Resource | Tools |
|---|---|
| Leads (full record, attribution, form answers, status history) | list_leads, get_lead |
| Customers + history | list_contacts, get_contact |
| Proposals / estimates (drafts, line items, prices, viewed / signed events) | list_estimates, get_estimate |
| Invoices & payments (Stripe + offline) — owner-finance | list_invoices, get_invoice, list_payments |
| Estimator calendars, open slots, booked visits | list_schedule |
| Jobs / production, crew, dates, check-ins | list_production, get_job_production |
| Photos with who / when / room / caption / approval | list_photos |
| Every customer email + text with delivery status | list_messages |
| Follow-ups: drip enrollments, queued sends, texting consent | list_followups |
| Staff roster, roles, titles, permissions | list_staff |
| Shifts, clock-ins, PTO | list_hours |
| Job costing: supplier invoices, materials — owner-finance | list_job_costs |
| Colors & products per job | list_job_colors |
| Reviews & referrals | list_reviews_referrals |
| Settings (read-only): contract types, deposit %, rates, catalog | get_settings |
| Activity / audit log | list_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)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)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).
send_proposal and share/publish aliases are blocked, bookings never arm reminders unless notifyCustomer: true, drips cannot be turned on, and assistant-created leads are not enrolled in drips.money-write-blocked; contract amounts, prices, rates, deposit % and tax on job / pricing writes return money-field-blocked. Proposal draft pricing is allowed because drafts are never sent by the key.persist_takeoff / save_takeoff are blocked. Create a draft with create_estimate, then edit rooms with save_proposal_draft.app-import. Use a manual key with that scope — see Import from another app.owner-finance scope on that key (or a legacy full-access key).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.
Org admins manage keys under Settings → Portal Ops keys at pezleyportal.com.