meeting-confirm implementation
Delivery and recovery
The attempt is inserted before calling. Result, attempt state, next time and pending operation flags are committed in one database transaction. There is no independent attempt counter: only dialing, done and unknown rows for the current meeting time count. A refused call has state not_placed.
Pending Raynet writes and Teams notices survive restart; an answered call still waiting for the agent becomes unclear at its ten-minute deadline. Delivery only happens from the running service. A failure leaves its flag pending for a five-minute retry, with at most three delivery passes per result. Migration 5 persists the pass count across restarts. Intermediate failures are logged as progress; the last failed pass writes one meeting-confirm.delivery_exhausted error without exporting CRM or message text. After exhaustion the delivery deadline is cleared and failed flags stay stored for diagnosis; the same result is not retried automatically. A new verdict or moved meeting starts a fresh budget. Teams uses an explicit personal destination by owner email and clears the notice only when delivery resolves. This depends on the Teams plugin reporting an undeliverable explicit destination as an error. A lost Teams acknowledgement can deliver a duplicate notice; Teams offers no idempotency receipt through this seam.
A scheduled callback or other-person retry remains blocked while its preceding result has active delivery work. Once that delivery exhausts its three-pass budget, the scheduled call becomes eligible again even if the failed Raynet flag remains set. The same eligibility condition drives due-call selection and the next timer wake, including after service restart. No failed write is marked delivered. The call still requires a valid phone, a live service account, an allowed calling slot and an unused phone attempt; CRM reads or phone normalization can still prevent dialing. Without a scheduled follow-up, exhaustion schedules no new call. Failure flags describe the current result until a new verdict or moved meeting replaces it; the exhaustion log and recorded attempt verdict remain the historical evidence.
Each Raynet note contains [Elowen #<attemptId>]. Missing phone, unreachable without an attempt and pilot outcomes use a generated operation ID instead. The service rereads the description before retrying a write; an existing marker prevents duplicate notes after a lost acknowledgement. Tags are merged and the previous description is retained. A pending result is delivered or exhausts its budget before a moved meeting replaces that result.
Boot reconciliation changes unfinished dialing attempts to unknown, counts them as used, closes the job and stores a notice. It sends nothing during boot. An explicit daemon_restart end reason always takes precedence over a normal result.
Data and costs
The plugin owns p_meeting_confirm_jobs and p_meeting_confirm_attempts in the host database. It stores meeting references, the contact name and phone read from the deal note with the note hash, the normalized phone, owner email, paying account, outcome message, pending delivery state, who recorded it and each attempt's call and conversation ids. Call transcripts are kept only in the conversation, where the account's normal conversation deletion removes them; upgrading to 0.1.4 clears transcripts earlier versions kept and retires the transcriptDays setting (elowen-plugin.json retiredConfigKeys; its label is no longer in the manifest or i18n/). Version 0.1.6 migration 4 renames the job's summary column to message, removes unused person_id, transcript_json, the old extracted outcome_json, and the unread created_at, updated_at, ended_at and seconds metadata, and stores agent verdicts in result_json. All jobs held at upgrade become undecided held jobs with an expired deadline. The running delivery loop records unclear and queues Raynet and Teams in the configured message language at startup, without another call. Deleting an account removes its jobs and attempts. Startup and daily cleanup also remove orphan accounts missed while the plugin was disabled.
The account captured when the meeting is discovered pays its attempts; changing the setting applies to new jobs and moved meeting times. The phone plugin and core own admission, voice access, spend limits and usage accounting. This plugin keeps no billing counter. Origin-attributed cost is read only from the host's usage_by_origin, under the phone provider and paying account.
Developer seams
| Seam | Use and timing | Absent behavior, limits and costs |
|---|---|---|
configSchema / requires.config | serviceAccount is a core user field (an account picker storing the username); callingDays multi-select defaults to Monday through Friday; settings UI/API and runtime use the same unique array of weekday options | The manifest is the only source of defaults. Core validates multi-select defaults against declared options. A saved selection replaces the default; empty, duplicate, unknown or serialized text selections fail runtime validation. Only the service account requires explicit configuration. |
ctx.control('raynetMeetings') | list({from,to,signal}) returns raw meetings; get(id) a raw meeting; person(id) the owner's raw person; businessCase(id) the raw deal with its note; update(id,{tags,description}) writes through Raynet's prepare/send path | Required control; no local HTTP client or credentials. Discovery and every attempt read CRM; failed reads are logged and leave work pending. |
ctx.control('phoneCalls') | normalize(raw) gives {e164} or {invalid:true}, and throws on outage. call({to,accountUserId,language,brief}) returns {dial,refused?,callId,seconds,transcript,endReason}; dial is answered, no_answer, busy, voicemail, failed or refused. A refusal is {dial:'refused',refused}. | Required control; no local telephony or voice engine. Provider owns dialing and external cost. Discovery reuses an unchanged contact's persisted normalized phone; every attempt normalizes again before dialing. This conversation's agent owns the answered call's result. Refusals count zero. |
ctx.host.defaultInference() (reads:['inference']) | One tool-free completion per changed deal note to read the customer contact | The workspace categorization model, or null outside a turn when none is configured; null leaves the job pending. Runs without an account: no spend gate and no account attribution. |
elowen-plugin-shared/zonedTime | Shared zonedParts, zonedTimeToMs, WEEKDAYS for all schedule calculations | No copied time-zone helper. Invalid configured zones are rejected before use. |
ctx.db().migrate/transaction | Namespaced schema and atomic attempt/result/outbox changes | Two plugin-owned tables, no core table changes or second usage source. Migration 4 removes obsolete columns and releases held pre-upgrade work as unclear. |
retiredConfigKeys | Removes the stored transcriptDays value | No other setting is touched. |
registerService/registerInterval | Meeting confirmation uses one timer service; intervals only flag discovery or cleanup and wake it. Recording MeetingConfirmRecord({callId, result:'callback', at, message}) arms the requested future call; due-call selection and the timer share the same eligibility condition. | Disabled service does no dialing/delivery; startup reconciles retained work. Active delivery blocks a follow-up until success or exhaustion after three persisted passes. Exhaustion retains failed flags without retrying that result or resetting the phone-attempt budget; a permitted scheduled follow-up can then proceed. No scheduled follow-up means no additional call. Existing call windows, account admission and provider costs still apply. |
registerBootReconcile/registerUserRemoved | Unknown-attempt recovery and account teardown | No sends during reconciliation; startup cleanup handles downtime. |
ctx.host.conversations().send (mutates:['conversations']) | After every placed call, { accountUserId, key: meetingId, title, text, display, call: { callId } } opens or continues the meeting's conversation as an agent turn billed to the service account | Requires core 0.29.79 and operator consent. A refused send, or a completed that rejects (failed or stopped turn, conversation busy with another turn), records unclear at once; a restart never settles completed, so the persisted ten-minute deadline records unclear. The call card is present only while core still holds its record of the call, and only because this plugin placed the call through phoneCalls. |
registerTool MeetingConfirmRecord | Inside that conversation's turn; reads ctx.currentSessionId() before any await and accepts callId, the validated result fields and message only for a call bound to that conversation | The service account needs the tool in its allowed tools. Providers receive one root object with optional time fields; the plugin's discriminated Zod contract requires the matching field before recording. The delivery loop stays the only writer; the tool waits at most a minute for the write and otherwise reports it as retried. |
ctx.notify | Explicit destination:msteams:<encoded email> for exception notices | Missing Teams delivery leaves notice pending; success depends on explicit non-delivery errors. |
ctx.alerts | Local administrator alert on typed call refusal | No customer notice for account/provider configuration failures. |
Raynet response shape
The consumer validates this response contract before placing a call:
- The meeting list (
GET /meeting/) has noowner. It is read forid,scheduledFrom,status,completedandtagsonly; a watched meeting is then read in detail. - The detail has
owner{id, fullName}. The owner is a Raynet person; the Teams address is that person'scontactInfo.email. An owner without a valid email fails the meeting loudly and nothing is called. statusis a string; besidesSCHEDULED,COMPLETEDandCANCELLEDRaynet also returnsNEW. OnlySCHEDULEDwith nullcompletedis watched.- The detail has
company{id, name}andbusinessCase{id, code, name}, each possibly null. The consumer reads the contact from the business case note instead of assuming the meeting's person is the customer. - The deal detail (
GET /businessCase/{id}/) supplies the HTMLdescriptionused for contact extraction. The consumer does not infer contact identity from a deal's person reference. - Updates take
tagsas one comma-separated string (Raynet's update schema).
A record that does not match fails validation and cannot cause a call.
Verification
Focused node tests use an in-memory SQLite database, a fake clock and mocked controls/Teams. They cover weekdays and DST, deadlines, exact callbacks, all outcome branches, moved meetings, derived counts, refusal, slow reads, restart priority, atomic rollback, pending delivery retries, lost Raynet acknowledgement, account removal, orphan cleanup, the meeting conversation and its retry turns, the record tool (one write, a foreign callId, nothing to write, a superseded call), the grace deadline and failed turns, pilot restrictions, malformed raw records, a list without owners, owners without email, the deal-note contact with its untrusted-data prompt, missing deals and notes, unusable model answers, a missing model and the cached extraction. Fixtures mirror the live response shape with synthetic values.
The generic settings form still needs real browser verification at mobile and desktop widths in Czech, Slovak and English: save/reload, required-field errors, help, missing control dependencies and console errors. External audio, the Raynet write, Teams delivery and the agent's turn in the conversation still require the owner-only pilot.