HelloLoop

Cloud architecture

Every hop as a flowchart. The website talks in forms. The phone room talks in voice. They never share a process.

Two planes

Cloud calling is two machines talking about the same ticket. The website never hears audio. The gateway is an always-on phone room; it never owns the wallet or who can see whose data. Gemini Live sits only on the voice side.

solid arrow = forms / records / APIs dashed arrow = live voice ticket = one attempt

Who can start

desk

Dashboard Call

An operator posts a number and a mission. Same rules as an assistant.

clock

Campaign pump

A minute clock next to the phone room. Claims one lead, then dials.

assistant

Claude / ChatGPT

Places a call through the same door. Wallet and DNC still run.

HTTPS forms

Control plane: the website. No sound.

dial

Place call

Checks, route, ticket, ask the carrier to ring.

answer

Answer recipe

Signed stream address after the lead picks up.

boot

Load the call

Gateway fetches the mission and the assigned agent.

end

Hangup

Status, duration, charge once.

records · carrier dial · signed stream

State: one workspace per customer. Rows stay inside that workspace.

ledger

Prepaid wallet

Balance checked before dial. Charged after hangup.

ticket

Live call ticket

One row per attempt. Status only moves forward.

history

Call history

What operators read. Same place for inbound, outbound, campaigns.

LIVE VOICE

Media plane: not the website. Always-on gateway.

phone company

Carrier

Rings the lead from your number. Streams voice to the gateway.

phone room

Gateway

Checks the ticket is real, bridges voice, writes the recording.

voice

Gemini Live

Talks, listens, lets the lead interrupt, calls tools.

phone ring

human

Lead phone

Hears the agent. Speaks into the carrier. Never talks to the website.

money

Wallet debit

Charged once per ticket. A second hangup cannot bill twice.

System map

Three starters, one place-call door, one ticket, one voice path. This page is cloud calling only.

flowchart TD
  UI["Dashboard Call"] --> PLACE["Place call"]
  CAMP["Campaign pump"] --> PLACE
  MCP["Assistant"] --> PLACE
  PLACE --> GATES["DNC · wallet · slot"]
  GATES --> TICKET["Create call ticket"]
  TICKET --> KEY["Start voice session"]
  KEY --> DIAL["Ask carrier to ring"]
  DIAL --> RING["Mobile rings"]
  RING --> XML["Signed stream recipe"]
  XML --> GW["Gateway checks ticket"]
  GW --> BOOT["Load mission and agent"]
  BOOT --> GEM["Gemini Live"]
  GEM --> TALK["Voice both ways"]
  TALK --> HANG["Hangup"]
  HANG --> BILL["Charge once"]
  BILL --> MP3["Save recording"]
  BILL --> LOG["Write history"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  classDef gem fill:#ece4f8,stroke:#8b6cc9,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  class UI,CAMP,MCP,PLACE,GATES,TICKET,KEY,DIAL,XML,BOOT office
  class GW,TALK,MP3 gate
  class GEM gem
  class BILL,LOG money
          

Before we dial

Dashboard, assistants, and the campaign clock share one door. A fail at any diamond never rings the carrier.

flowchart TD
  IN["Number plus mission"] --> AUTH{"Signed in?"}
  AUTH -->|no| STOP1["Stop"]
  AUTH -->|yes| EMER{"Emergency number?"}
  EMER -->|yes| STOP2["Blocked"]
  EMER -->|no| NORM["Normalize the number"]
  NORM --> DNC{"On the DNC list?"}
  DNC -->|yes| STOP3["Do not call"]
  DNC -->|no| ROUTE["Pick a carrier"]
  ROUTE --> WAL{"Wallet covers 1 min?"}
  WAL -->|no| STOP4["Ask to top up"]
  WAL -->|yes| SLOT{"Under the call cap?"}
  SLOT -->|no| WAIT["Try again shortly"]
  SLOT -->|yes| DID["Need an outbound number"]
  DID --> ROW["Create ticket"]
  ROW --> LOG["Open history row"]
  LOG --> REST["Carrier rings"]
  REST --> OK["Call started"]
  REST --> FAIL["Failed. Free the slot"]

  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  class STOP1,STOP2,STOP3,STOP4,WAIT,FAIL stop
  class IN,AUTH,NORM,ROUTE,DID,ROW,LOG,REST,OK office
  class WAL money
          

Voice credentials never go to the browser. The desk can read the ticket. It cannot read the key.

Outbound sequence

Read left to right, then down. After the stream is up, the website only handles hangup and tools. Voice stays carrier to gateway to Gemini.

Until the phone rings

sequenceDiagram
  actor You
  participant Web as Website
  participant P as Carrier
  actor Lead

  You->>Web: Call this number
  Web->>Web: DNC, route, wallet
  Web->>P: Please ring them
  P->>Lead: Phone rings
  Lead->>P: Answer
  P->>Web: They picked up
  Web-->>P: Signed stream address
          

After they pick up

sequenceDiagram
  participant P as Carrier
  actor Lead
  participant G as Gateway
  participant Gem as Gemini
  participant Web as Website

  P->>G: Open the voice stream
  G->>Web: Load this ticket
  Web-->>G: Mission and agent
  G->>Gem: Open Live
  loop Talk
    Lead->>P: Speak
    P->>G: Voice frames
    G->>Gem: Hear the lead
    Gem->>G: Hear the agent
    G->>P: Play the agent
    P->>Lead: Hear the agent
  end
  Lead->>P: Hangup
  P->>Web: Call ended
  G->>Web: Session and recording
  Web->>Web: Charge once
          

After they answer

The answer address is not a public audio socket. The carrier must prove the request. The recipe then points at the gateway with a short-lived signed ticket. Guessing the link fails.

flowchart TD
  ANS["Lead answers"] --> POST["Carrier asks for recipe"]
  POST --> SIG{"Request proven?"}
  SIG -->|no| REJ["Refuse"]
  SIG -->|yes| URL["Build stream address"]
  URL --> TOK["Sign the ticket"]
  TOK --> XML["Return the recipe"]
  XML --> OPEN["Carrier opens the stream"]
  OPEN --> GW{"Ticket matches?"}
  GW -->|no| CLOSE["Close the socket"]
  GW -->|yes| BOOT["Load the call"]
  BOOT --> PERS["Pick the agent"]
  PERS --> LIVE["Open Gemini Live"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  classDef gem fill:#ece4f8,stroke:#8b6cc9,color:#1c1712
  class ANS,POST,SIG,URL,TOK,XML,BOOT,PERS office
  class OPEN,GW gate
  class REJ,CLOSE stop
  class LIVE gem
          

Routing

We pick a carrier from the destination number, not from who the customer is. This page is the cloud path.

flowchart TD
  START["Place call"] --> DEST{"Domestic number?"}
  DEST -->|yes| PL["Primary domestic carrier"]
  PL -->|not configured| VL["Domestic fallback"]
  DEST -->|no| TX["Primary international"]
  TX -->|not configured| PL2["International fallback"]
  PL --> STREAM["Signed stream to gateway"]
  VL --> STREAM
  TX --> STREAM
  PL2 --> STREAM

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef carrier fill:#fde8dc,stroke:#d46a32,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  class START,DEST office
  class PL,VL,TX,PL2 carrier
  class STREAM gate
          

Domestic and international each have a primary and a fallback. Every path still lands on the same signed stream into the gateway.

Website

Screens write forms. Carriers report answer and hangup. The gateway asks for the ticket and sends the recording back. One engine behind every starter.

flowchart TD
  DESK["Desk"] --> PLACE["Place call"]
  MCP["Assistant"] --> PLACE
  CRON["Minute clock"] --> PUMP["Claim next lead"]
  PUMP --> PLACE
  PLACE --> CORE["Same dial engine"]
  CORE --> CARRIER["Carrier rings"]
  CARRIER --> XML["Answer recipe"]
  CARRIER --> WH["Hangup"]
  GW["Gateway"] --> BOOT["Load the ticket"]
  GW --> REP["Send session back"]
  XML --> GW
  WH --> BILL["Charge wallet"]
  REP --> CLOG["Write transcript"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  class DESK,MCP,PLACE,CORE,PUMP,XML,WH,BOOT,REP office
  class GW,CARRIER gate
  class BILL,CLOG,CRON money
          

Gateway

The phone room. If it is down, the website can still look fine and cloud calling is still dead. Audio never transits the website.

flowchart TD
  IN["Carrier stream"] --> TOK{"Ticket real?"}
  TOK -->|no| X["Close"]
  TOK -->|yes| GET["Load the call"]
  GET --> NEED{"Mission ready?"}
  NEED -->|no| X
  NEED -->|yes| LIVE["Open Gemini Live"]
  LIVE --> LOOP["Voice loop"]
  LOOP --> UP["Hear the lead"]
  LOOP --> DOWN["Speak to the lead"]
  LOOP --> BARGE{"Interrupted?"}
  BARGE -->|yes| CLR["Stop playback"]
  LOOP --> BUF["Record both sides"]
  BUF --> FF["Save the recording"]
  FF --> REP["Send session back"]
  LOOP --> TOOL["Agent uses a tool"]
  TOOL --> WAIT{"Website confirmed?"}
  WAIT -->|yes| TELL["Agent may say it"]
  WAIT -->|no| HOLD["Do not claim it"]

  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  classDef gem fill:#ece4f8,stroke:#8b6cc9,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  class IN,TOK,GET,LOOP,UP,DOWN,CLR,BUF,FF,REP gate
  class LIVE,BARGE,TELL gem
  class X,HOLD stop
  class TOOL,WAIT,NEED office
          

Every carrier, one room

flowchart TD
  P["Domestic carrier"] --> ROOM["Same voice room"]
  T["International carrier"] --> ROOM
  V["Fallback carrier"] --> ROOM
  B["In-app test call"] --> ROOM
  ROOM --> GEM["Gemini Live"]
              

Barge-in

flowchart TD
  SPK["Agent speaking"] --> INT{"Lead talks?"}
  INT -->|no| KEEP["Keep playing"]
  INT -->|yes| GRACE{"Still greeting?"}
  GRACE -->|yes| SKIP["Let the greeting finish"]
  GRACE -->|no| CLR["Stop and listen"]
              

Live audio

Gemini owns turn-taking. The gateway is a pipe plus a recorder. It does not mute the lead on its own.

sequenceDiagram
  participant Lead
  participant Carrier
  participant Gateway
  participant Gemini

  Lead->>Carrier: Speak
  Carrier->>Gateway: Voice in
  Gateway->>Gemini: Hear the lead
  Gemini-->>Gateway: Hear the agent
  Gateway->>Carrier: Play the agent
  Carrier->>Lead: Speaker
  Gemini-->>Gateway: Interrupted
  Gateway->>Carrier: Stop playback
  Gemini-->>Gateway: Use a tool
  Gateway->>Gateway: Ask the website
  Gateway->>Gemini: Result after it lands
          

Hangup and bill

Carriers may report the same hangup more than once, and late. A finished call stays finished. A late “stream stopped” cannot reopen it or steal a line.

flowchart TD
  H["Hangup or failed"] --> MAP["Read the outcome"]
  MAP --> RANK{"Newer than current?"}
  RANK -->|no| IGNORE["Ignore"]
  RANK -->|yes| UPD["Update the ticket"]
  UPD --> CL["Update history"]
  UPD --> Q["Free the queue slot"]
  UPD --> KEY["End the voice session"]
  CL --> DEB["Charge the wallet"]
  DEB --> ID{"Already billed?"}
  ID -->|yes| SKIP["No second charge"]
  ID -->|no| CENTS["Write the ledger"]
  UPD --> EVT["Keep an event"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  class H,MAP,RANK,UPD,CL,Q,KEY,EVT office
  class DEB,ID,CENTS money
  class IGNORE,SKIP stop
          

Inbound

Your number is a doorbell. Same gateway as outbound. Unknown numbers, including shared demo numbers, are rejected so they cannot land on another workspace.

flowchart TD
  CALL["Caller dials your number"] --> WH["Carrier reports it"]
  WH --> MATCH{"Your number?"}
  MATCH -->|no or demo| REJ["Reject"]
  MATCH -->|yes| ORG["Your workspace and agent"]
  ORG --> T["New inbound ticket"]
  T --> LOG["Open history"]
  LOG --> XML["Same signed stream"]
  XML --> G["Gateway loads the call"]
  G --> GEM["Gemini Live"]
  GEM --> FIN["Hangup plus transcript"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  classDef gem fill:#ece4f8,stroke:#8b6cc9,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  class CALL,WH,MATCH,ORG,T,LOG,XML office
  class G,FIN gate
  class GEM gem
  class REJ stop
          

Campaigns

A campaign is a list plus a pace. Each tick uses the same dial path as the Call button. The clock lives next to the phone room so it keeps moving even if the website is asleep.

flowchart TD
  Q["Queue waiting"] --> CRON["Minute clock"]
  CRON --> PUMP["Wake the campaign"]
  PUMP --> REAP["Close stuck tickets"]
  PUMP --> RETRY["Wake retries"]
  PUMP --> CLAIM["Claim the next lead"]
  CLAIM --> OK{"Wallet and slot ok?"}
  OK -->|no| WAIT["Wait one minute"]
  OK -->|yes| SAME["Same cloud dial"]
  SAME --> RING["One lead rings"]
  RING --> Q

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  class Q,CRON,PUMP,REAP,RETRY,CLAIM,SAME office
  class OK,WAIT money
  class RING gate
          

Start and each tick check the wallet for one minute at that destination. Empty wallet stops new dials. It does not hang up a live call.

Wallet

Prepaid. We check that the next minute can be paid before anyone rings. We charge after hangup. Rates live on the pricing page, not here.

flowchart TD
  ASK["Want to dial"] --> PRE{"Wallet covers 1 min?"}
  PRE -->|no| STOP["Do not dial"]
  PRE -->|yes| DIAL["Carrier rings"]
  DIAL --> HANG["Hangup"]
  HANG --> DEB["Charge the call"]
  DEB --> ID{"Already billed?"}
  ID -->|yes| SKIP["No second charge"]
  ID -->|no| WRITE["Write the ledger"]

  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  class ASK,PRE,DIAL,HANG,DEB,ID,WRITE money
  class STOP,SKIP stop
          

Top-up credits a payment once, from the return page and from the processor webhook. Number rent is a separate cycle. An overdue number stays up so production does not go silent overnight.

Concurrency

Cloud is not one call worldwide. Each workspace has a cap. Stuck tickets are closed so they cannot hold a line forever.

flowchart TD
  NEW["New cloud dial"] --> CNT["Count live tickets"]
  CNT --> STALE{"Stuck ticket?"}
  STALE -->|yes| REAP["Mark it failed"]
  STALE -->|no| N["Live plus 1"]
  N --> CAP{"Over the cap?"}
  CAP -->|yes| BLOCK["Do not dial"]
  CAP -->|no| GO["Create ticket and dial"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  class NEW,CNT,STALE,N,CAP,GO office
  class REAP,BLOCK stop
          

What we store

flowchart TD
  ORG["Workspace"] --> SET["Settings"]
  ORG --> PER["Agents"]
  ORG --> CON["Contacts"]
  ORG --> DID["Your numbers"]
  ORG --> DIS["Live tickets"]
  ORG --> LOG["Call history"]
  ORG --> WAL["Wallet ledger"]
  ORG --> EVT["Call events"]
  CAMP["Campaigns"] --> Q["Campaign queue"]
  Q --> DIS
  DIS --> LOG
  DID --> DIS
  DIS --> EVT
  PER --> DIS

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  class ORG,SET,PER,CON,DID,DIS,LOG,EVT,CAMP,Q office
  class WAL money
          

The live ticket is the attempt in flight. History is what the desk reads. Events are an append-only trail. Wallet rows are never edited in place.

Assistants

Claude or ChatGPT can place a call, read history, or start a campaign. Same door, same wallet, same DNC. There is no “just enqueue” shortcut.

flowchart TD
  C["Assistant"] --> A{"Signed in?"}
  A -->|no| STOP["Refuse"]
  A -->|yes| S["Assistant tools"]
  S --> T["Place a call"]
  T --> R["Same DNC and wallet"]
  R --> D["Same dial engine"]
  D --> CAR["Carrier rings"]
  S --> LIST["List call history"]
  LIST --> LOG["History"]
  S --> START["Start a campaign"]
  START --> PLANE["Same minute clock"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef money fill:#fff3d1,stroke:#c9a227,color:#1c1712
  classDef stop fill:#f8d7d3,stroke:#c0392b,color:#1c1712
  class C,A,S,T,R,D,LIST,LOG,START,PLANE office
  class CAR money
  class STOP stop
          

Workspaces

Each customer is a workspace. A signed-in user only sees that workspace. The gateway loads one ticket. A carrier report only updates the matching call.

flowchart TD
  USER["Signed-in user"] --> MEM["Workspace member"]
  MEM --> ORG["That workspace"]
  ORG --> READ["Desk reads own rows"]
  GW["Gateway"] --> BOOT["Load one ticket"]
  BOOT --> LIVE["Open that voice session"]
  CAR["Carrier report"] --> SIG["Prove it is the carrier"]
  SIG --> MATCH["Match the ticket"]
  MATCH --> WRITE["Update that call only"]

  classDef office fill:#e8f4fb,stroke:#3d8fb5,color:#1c1712
  classDef gate fill:#dcf5ea,stroke:#2a9d6e,color:#1c1712
  class USER,MEM,ORG,READ,CAR,SIG,MATCH,WRITE office
  class GW,BOOT,LIVE gate
          

What this architecture guarantees

  • Audio never goes through the website. That is why the gateway exists.
  • One dial path. Dashboard, campaigns, and assistants share it.
  • The agent cannot claim a booking until the tool write succeeds.
  • Stream links are signed, not public. Guessing the address fails.
  • Campaigns keep moving even if the website is asleep.
  • No live transfer. One phone leg.
  • A finished call stays finished. Status only moves forward.

Moving version of the same boxes: Interactive.