APIs, webhooks & tools
In one line: everything the system calls out to (Business Central, AgentMail, the optional finance API, the CDN), everything that calls in (11 webhooks), and every action the LLM can take (the tool catalog) — plus the preprocessor/postprocessor pipeline that wraps each turn.
What it is
HIGHFIELD talks to the outside world over three lanes:
- Outbound to Business Central (BC) — its system of record. Two BC API surfaces: the standard v2.0 API and a custom per-tenant extension API (
propMaint). All BC calls authenticate with an OAuth2 client-credentials token from Microsoft Entra (Azure AD). - Outbound email — the production lane via Lua's
send-messageAPI, plus a separate AgentMail client; an optional finance API fires only if its env vars are set. See Communication & email. - Inbound webhooks — 11 endpoints registered at
src/index.ts:552-564, reachable athttps://webhook.heylua.ai/{agentId}/{name}. The most important isinbound-email. Most others are unauthenticated internal/ops tools; onlyapproval-decisionis HMAC-protected.
A platform constraint worth flagging: Lua webhooks are POST-only, so the old browser-clickable "Approve/Reject" magic links (a GET) always 404'd — approvals now flow through email replies on
inbound-email.
Integration overview
- BC standard API. Base
https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/v2.0(bc-client.ts:18-22). Reads customers (tenants), vendors, property dimension values; creates/posts purchase orders, purchase invoices, sales invoices, GL journal lines. Tickets are NOT BCprojects— they're JSONdocumentAttachments(ticket-{id}.json,audit-{id}.json) on the tenant'scustomersrecord (bc-entities.ts:494-496,638-645). - BC custom extension ("propMaint"). Base
.../api/propMaint/v1/v1.0/companies(bc-custom-api.ts:20-24). A dual-write, fire-and-forget mirror of the attachment source-of-truth; failures logged, never propagated. Exposes a custompostPurchaseOrderaction the standard API can't do. - Microsoft Entra ID (OAuth2). Token issuer for all BC calls; client-credentials against
login.microsoftonline.com/{tenantId}/oauth2/v2.0/token, scope…/.default(bc-auth.ts:10-11). Cached, refreshed 5 min before expiry. - Lua send-message API.
POST https://api.heylua.ai/developer/agents/{agentId}/send-message, BearerLUA_API_KEY. The production outbound-email mechanism. - AgentMail.
https://api.agentmail.to/v0, BearerAGENTMAIL_API_KEY. Dedicated mailbox; everytoforcibly rewritten toAGENTMAIL_OVERRIDE_TO(defaultstefan@heylua.ai). - Finance API (optional / likely dormant). Tools
POST {FINANCE_API_URL}/approvalsand/paymentsonly if both env vars set. ⚠️ Unverified whether configured anywhere. - Lua CDN.
https://cdn.heylua.ai/{fileId}.{ext}; images re-hosted here (BC caps image-URL at 500 chars, so raw S3 URLs are rejected).
Reference — outbound endpoints
Auth: OAuth2 = Bearer from Entra; LuaKey = LUA_API_KEY; AMKey = AGENTMAIL_API_KEY; FinKey = FINANCE_API_KEY.
| System | Operation | Method | Path | Auth | Source |
|---|---|---|---|---|---|
| Entra OAuth2 | Acquire token | POST | login.microsoftonline.com/{tenantId}/oauth2/v2.0/token | id+secret | bc-auth.ts:10-11,40-54 |
| BC standard | List companies | GET | /companies | OAuth2 | bc-client.ts:100 |
| BC standard | List entities | GET | /companies({id})/{entity}{?$filter,$top} | OAuth2 | bc-client.ts:118; bc-entities.ts:121.. |
| BC standard | Get entity by id | GET | /companies({id})/{entity}({entityId}) | OAuth2 | bc-client.ts:133 |
| BC standard | Get nested entity | GET | /companies({id})/{parent}({pid})/{child} | OAuth2 | bc-client.ts:150 |
| BC standard | Create entity | POST | /companies({id})/{entity} | OAuth2 | bc-client.ts:166 |
| BC standard | Create nested entity | POST | /companies({id})/{parent}({pid})/{child} | OAuth2 | bc-client.ts:182 |
| BC standard | Update entity | PATCH | /companies({id})/{entity}({entityId}) (needs If-Match) | OAuth2 | bc-client.ts:201 |
| BC standard | Delete entity | DELETE | /companies({id})/{entity}({entityId}) | OAuth2 | bc-client.ts:217 |
| BC standard | Bound action: receive+invoice PO | POST | …/purchaseOrders({poId})/Microsoft.NAV.receiveAndInvoice | OAuth2 | bc-client.ts:231; bc-entities.ts:1060 |
| BC standard | Upload attachment (metadata) | POST | …/documentAttachments | OAuth2 | bc-client.ts:251 |
| BC standard | Upload attachment (content) | PATCH | …/documentAttachments({attId})/attachmentContent (If-Match) | OAuth2 | bc-client.ts:262-273 |
| BC standard | Read attachment content (the ticket-JSON store) | GET | …/documentAttachments({attId})/attachmentContent | OAuth2 | bc-client.ts:287 |
| BC custom | Upsert ticket/activity/image/quote/approval/message/escalation | POST/PATCH | …/propMaint/v1/v1.0/companies({id})/{table} | OAuth2 | bc-custom-api.ts:85,95,181-768 |
| BC custom | Custom action: post purchase order | POST | …/companies({id})/postPurchaseOrder | OAuth2 | bc-custom-api.ts:443-458 |
| BC custom | Read views/setup | GET | …/companies({id})/{view}{?$filter} | OAuth2 | bc-custom-api.ts:113-122,572-693 |
| Lua send-message | Send email | POST | api.heylua.ai/developer/agents/{agentId}/send-message | LuaKey | email-notifications.ts:150-157 |
| AgentMail | Ensure/create inbox | POST | api.agentmail.to/v0/inboxes | AMKey | agentmail.ts:68-74 |
| AgentMail | Send message | POST | /inboxes/{inboxId}/messages/send (to forced) | AMKey | agentmail.ts:110-113 |
| AgentMail | Reply to thread | POST | /inboxes/{inboxId}/messages/{messageId}/reply | AMKey | agentmail.ts:139 |
| AgentMail | Get attachment info | GET | /inboxes/{inboxId}/messages/{messageId}/attachments/{attachmentId} | AMKey | agentmail.ts:160-162 |
| AgentMail | Register webhook | POST | /webhooks (message.received) | AMKey | agentmail.ts:180-187 |
| Lua CDN | Download / re-host image | GET/upload | cdn.heylua.ai/{fileId}.{ext} via CDN.upload() | platform | inbound-email.webhook.ts:251 |
| Finance API (opt) | Submit approval | POST | {FINANCE_API_URL}/approvals | FinKey | SendForApprovalTool.ts:180-187 |
| Finance API (opt) | Initiate payment | POST | {FINANCE_API_URL}/payments | FinKey | InitiatePaymentTool.ts:124-131 |
Environment switching is purely the
BC_ENVIRONMENTenv var (default'Production') interpolated into the BC URL — no code-level prod/sandbox branch.BC_TENANT_ID/BC_CLIENT_ID/BC_CLIENT_SECRETdefault to hardcoded values if unset (bc-auth.ts:21-24). See Security and Configuration.
Reference — inbound webhooks
All registered in src/index.ts:552-564. Route = https://webhook.heylua.ai/{agentId}/{name}. Unless noted, all return HTTP 200 with a JSON body even on validation failure (errors are in the body, not the status).
| Name | Method | Auth | Key payload | Side effects | Source |
|---|---|---|---|---|---|
inbound-email | POST | AgentMail event shape only (message.received) — no signature | message: message_id, from, subject, text, html, attachments[], headers, in_reply_to, references, thread_id | Loop/rate guard; thread-id derivation; approver-reply interception; image→CDN (HEIC→JPEG); resolve sender; auto-create ticket OR dispatch vendor tool; hallucinated-ID stripping; sends reply; dual logCommunication | inbound-email.webhook.ts:37-1099 |
approval-decision | GET (query.token) | HMAC-SHA256 token (APPROVAL_TOKEN_SECRET, 7-day TTL, constant-time) | token (encodes ticketId, companyId, decision, expiresAt) | applyFinanceDecision → status transition + notify. Method mismatch: browser GET vs Lua POST → legacy links 404'd | approval-decision.webhook.ts:21-95 |
finance-approval | POST | None | companyId, ticketId, approved, approverName, … (ticketId /^MT-\d{4}-\d{3,}$/) | applyFinanceDecision → status + approval fields, downstream PO/invoice + notifications | finance-approval.webhook.ts:27-84 |
escalation-response | POST | None | companyId, ticketId, action, resolverName, … | reassign vendor / override status / resolve escalation / notify; in-app only (no email) | escalation-response.webhook.ts:28-191 |
open-tickets | POST or GET | None | companyId | Read-only: list tickets (excl. closed/rejected/cancelled), CDN-format images, sort | open-tickets.webhook.ts:18-146 |
clear-data | POST | Safety flag only (confirm:true) | confirm, companyId? | Destructive: deletes agent-test tickets (MT-…) + audit attachments | clear-data.webhook.ts:21-98 |
seed-data | POST | None | companyId?, createDemoProjects? | Verifies BC reads; optionally creates 2 demo tickets | seed-data.webhook.ts:21-253 |
create-vendor | POST | None | companyId, vendorId, specialties[], … | Writes vendor-data.json extended-data on the BC vendor | vendor-management.webhook.ts:28-110 |
list-vendors | POST or GET | None | companyId, filters | Read-only: list vendors + extended data | vendor-management.webhook.ts:126-264 |
assign-vendor | POST | None | companyId, ticketId, vendorId, … | Validates ticket reported + specialty/area; sets → vendor_contacted | vendor-management.webhook.ts:297-493 |
vendor-response | POST | None | companyId, ticketId, vendorId, type, quote?/completion?/… | quote → pending_approval + creates BC PO; work_completed → completed + conditional invoice post (BC_AUTO_POST_INVOICE); tenant notifications | vendor-response.webhook.ts:42-457 |
⚠️ Security: 9 of 11 webhooks have no auth — anyone with the public URL can POST. Most dangerous:
clear-data,finance-approval,escalation-response,assign-vendor. See Security.
Reference — tool catalog
Tools register through three skills (src/index.ts:549). Names below are the literal strings the LLM calls.
Intake (tenant-facing) — property-maintenance skill
| Tool | Purpose | Side effects | Guards | Source |
|---|---|---|---|---|
get_user_context | Identify caller from phone/email; cache BC identity | persists bcIdentity; no BC write/email | test-override only when BC_ENVIRONMENT≠Production; blocks unregistered | GetUserContextTool.ts:13 |
create_maintenance_ticket | Create one ticket (delegates to auto-assign service) | BC create → REPORTED; vendor+tenant emails; seeds email_msgid_ticket | overrides agent IDs from bcIdentity; tenant-identity duplicate check | CreateMaintenanceTicketTool.ts:21 |
my_tickets | Tenant views/updates/cancels their own tickets | update→BC; cancel→CANCELLED; audit | owner gate on every action; image cap 10 | MyTicketsTool.ts:18 |
search_maintenance_history | NL search of history + patterns | read-only | ⚠️ no owner scope, no role gate | SearchMaintenanceHistoryTool.ts:12 |
update_ticket_details | Patch ticket fields (no status, no images) | BC patch; audit | ID/empty/existence; ⚠️ no owner check | UpdateTicketDetailsTool.ts:13 |
upload_issue_images | URLs → CDN → attach to ticket + notify vendor | CDN; BC patch + syncImages; vendor email | type allow-list; cap 10; ⚠️ no owner check | UploadIssueImagesTool.ts:15 |
Vendor self-service — vendor-management skill
| Tool | Purpose | Side effects | Guards | Source |
|---|---|---|---|---|
list_available_jobs | List reported, unassigned matching jobs | read-only | vendor existence; specialty match | ListAvailableJobsTool.ts:10 |
claim_job | Vendor self-assigns → vendor_contacted | BC patch; audit | active-job-limit (maxVendorActiveJobs, def 5); status reported | ClaimJobTool.ts:13 |
decline_job | Decline → resets to reported, escalates | clears assignment; BC escalation; admin+tenant notice | resolveVendorId; status vendor_contacted/quoted | DeclineJobTool.ts:16 |
my_assigned_jobs | List vendor's own jobs | read-only | implicit via vendorNumber | MyAssignedJobsTool.ts:9 |
submit_quote | Cost + earliest time; resolves cost gate | BC → pending_tenant_confirmation/pending_approval; emails; no PO (deferred to lock-in) | owner; status gate; cost-threshold gate | SubmitQuoteTool.ts:23 |
submit_revised_quote | Revised scope/cost | archives prior; cancels+recreates PO | auto-approve only if ≤ prior AND ≥20% of original | SubmitRevisedQuoteTool.ts:21 |
offer_visit_slots | The only tool that sends the tenant a visit menu | BC (offeredSlots); tenant chat+email | feature-flag; owner; status gate; anti-fabrication of dates | OfferVisitSlotsTool.ts:29 |
start_work | → in_progress / resume from on_hold | BC patch; audit; tenant notice | owner; status approved/on_hold | StartWorkTool.ts:15 |
pause_work | → on_hold | BC patch; audit; tenant notice | owner; status in_progress | PauseWorkTool.ts:15 |
complete_job | Complete with photos + invoice → completed | status; BC PO receive/invoice gated by BC_AUTO_POST_INVOICE; emails | photos ≥ minCompletionPhotos (def 1); owner; status in_progress | CompleteJobTool.ts:17 |
reschedule_visit | Vendor sets/moves their visit date | BC (TZ-anchored ISO); conditional lock-in; tenant notice | owner; finished-status block; TZ re-anchor | RescheduleVisitTool.ts:26 |
validate_invoice | Cross-check invoice vs ticket — verdict only | read-only | owner; status; >10% over = blocking | ValidateInvoiceTool.ts:16 |
upload_vendor_photos | Vendor photo URLs → CDN | CDN; for completion: BC patch + syncImages | type allow-list; ⚠️ no owner check | UploadVendorPhotosTool.ts:14 |
Agent-side vendor + escalation — property-maintenance skill
| Tool | Purpose | Side effects | Source |
|---|---|---|---|
lookup_vendors | Rank active vendors for an issue | read-only | LookupVendorsTool.ts:10 |
send_vendor_request | Agent assigns a vendor → vendor_contacted | BC patch; audit. ⚠️ no email in source despite copy | SendVendorRequestTool.ts:13 |
record_vendor_quote | Agent records a quote → pending_approval + BC PO | BC patch; creates BC PO; audit | RecordVendorQuoteTool.ts:13 |
update_vendor_metrics | Recompute vendor performance | BC vendor extended data; audit | UpdateVendorMetricsTool.ts:12 |
relay_tenant_reschedule | Relay tenant's time request to vendor | BC (tenantRescheduleRequest); vendor email; system-cancel at 3rd push | RelayTenantRescheduleTool.ts:35 |
escalate_ticket | BC escalation + email admins | BC escalation (OPEN); escalatedAt; admin emails | EscalateTicketTool.ts:26 |
Approval — property-maintenance skill
| Tool | Purpose | Side effects | Source |
|---|---|---|---|
check_approval_threshold | Is this quote auto-approvable? | read-only (⚠️ historical spend hardcoded 0) | CheckApprovalThresholdTool.ts:13 |
send_for_approval | Auto-approve (≤ threshold) or route to finance | auto → APPROVED + tenant chat; finance → PENDING_APPROVAL + approver emails | SendForApprovalTool.ts:22 |
initiate_payment | Authorize payment for completed work | optional finance POST; BC payment; audit | InitiatePaymentTool.ts:17 |
Approvals are NOT a tool. Cost approve/reject is recorded automatically when the authorized approver replies YES/NO to the approval email (
src/index.ts:517-524). The agent has no approve/reject tool.
Completion — property-maintenance skill
| Tool | Purpose | Side effects | Source |
|---|---|---|---|
confirm_visit_time | Tenant confirms time → lock-in (if cost approved) | BC; vendor notify; lockInTicket or tenantTimeConfirmed | ConfirmVisitTimeTool.ts:20 |
record_tenant_feedback | Capture feedback (no status change) | BC feedback fields; audit; nextStep | RecordTenantFeedbackTool.ts:19 |
close_ticket | Close completed ticket after checks | BC → CLOSED; optional BC Sales Invoice; tenant chat | CloseTicketTool.ts:18 |
record_completion_docs | Record vendor photos+invoice → completed | CDN; BC → COMPLETED; audit | RecordCompletionDocsTool.ts:18 |
record_tenant_dispute | Dispute completed work → disputed + escalate | BC → DISPUTED; escalation; admin emails | RecordTenantDisputeTool.ts:15 |
request_tenant_confirmation | Ask tenant to confirm satisfaction | BC; tenant chat; creates one-time reminder Job | RequestTenantConfirmationTool.ts:18 |
notify_tenant_access | Tell tenant contractor needs access | tenant chat + email; no field write | NotifyTenantAccessTool.ts:12 |
Admin (approver-only) — admin skill
All four self-guard with if (!user?.isAdmin) → "admin-only".
| Tool | Purpose | Source |
|---|---|---|
admin_query_tickets | "How many / who / which" — code-computed aggregates + optional list | AdminQueryTool.ts:18 |
admin_resolve_escalation | Relay admin's instruction to tenant/vendor; resolve escalation (not finance) | AdminResolveEscalationTool.ts:21 |
admin_get_ticket | Full detail of one ticket (incl. last 10 events) | GetTicketTool.ts:13 |
list_pending_approvals | Tickets currently pending_approval | ListPendingApprovalsTool.ts:13 |
The processing pipeline
Preprocessors (by priority, lower runs first)
| Priority | Name | What it does | Channel |
|---|---|---|---|
| 5 | auto-reply-guard | Blocks automated senders + rate-limited senders (mail-loop prevention) | all |
| 10 | emergency-triage | Life-threatening keywords → hardcoded safety template; else prepends [SYSTEM: URGENT]; injects open-ticket count + property block | all |
| 15 | admin-mode | If sender ∈ APPROVER_EMAIL/BC approval emails, persists user.isAdmin | all |
| 20 | user-context | Admins bypass; cache-hit fast path; blocks unregistered; persists bcIdentity | all |
| 21 | identity-injection | Prepends [SYSTEM CONTEXT — DO NOT REPEAT] identity block | all (skips admins) |
| 22 | current-thread-ticket-injection | 4-tier ownership-gated resolver of the thread's ticket; [CURRENT THREAD HINT]; seeds email_msgid_ticket; blocks cross-tenant binds | email-only (skips admins) |
| 23 | role-content-guard | Strips synthetic blocks; hard-refuses role-mismatched content | all |
| 25 | vendor-action-extractor | Deterministic dispatcher: runs the vendor BC-write tool directly (bypassing the LLM), then injects [AUTHORITATIVE OUTCOME] | email-only |
| 30 | email-channel-handler | Re-uploads non-CDN image URLs to CDN; drops video/audio; extracts inline base64; loads [PRIOR THREAD] history | email-only |
Postprocessors (execution = array order)
| Order | Name | What it does |
|---|---|---|
| 1 | collapse-doubled-narration | Strips the model's pre-tool intent narration glued in front of the real reply |
| 2 | tool-call-validator | Anti-hallucination net: verifies cited MT- IDs against BC + ownership; rewrites to a safe fallback on a hallucinated claim |
| 3 | ticket-summary | Stamps a property+status card (non-email only; cross-tenant gate) |
| 4 | communication-log | Audit writer: strips synthetic blocks, writes inbound+outbound to BC; never alters the response |
| 5 | suppress-on-ticket-creation | The one full-blank stage: returns '' when justCreatedTicket/justConfirmedVisit set |
| 6 | sanitize-admin-output | Admin turns only: strips leaked reasoning |
| 7 | admin-reply-format | Admin turns only: renders Markdown reply to email-safe HTML |
| — | sanitize-vendor-output | INACTIVE — imported but not in the array (dropped in a bisect) |
Skills/persona behavioral rules (money-blindness, urgency, one-ticket-per-email, two-gate, photo policy) are summarized in Core concepts and authored at src/index.ts:71-546.
Gotchas & failure modes
- Inconsistent ownership enforcement. Owner gates exist on
my_tickets,confirm_visit_time,record_tenant_feedback, but not onsearch_maintenance_history,update_ticket_details,upload_issue_images,close_ticket,record_completion_docs,record_tenant_dispute,request_tenant_confirmation,notify_tenant_access,upload_vendor_photos,update_vendor_metrics. They rely on therole-content-guard+ thread-ownership gates instead. See Security. - The LLM is bypassed for vendor email side-effects.
vendor-action-extractorcalls the BC-write tool itself, then injects[AUTHORITATIVE OUTCOME]. If the persona rule (src/index.ts:540-546) isn't honored, the model could double-execute. record_vendor_quote/submit_revised_quotecreate the PO inline;submit_quotedefers PO to lock-in.complete_job's BC receive/invoice is gated byBC_AUTO_POST_INVOICE(default OFF).- Photo tool-choice trap.
update_ticket_detailsdoes NOT write images; onlyupload_issue_images/upload_vendor_photosdo. - Custom-API writes are fire-and-forget. The propMaint mirror swallows errors; the BC card can silently drift from the attachment source of truth.