AG-UI · Agent-User Interaction Protocol

Real-time agent
events across all portals.

AG-UI replaces one-shot API calls with a live event stream — negotiation rounds, price updates, LOI status, and boss approvals flow to every portal in real time. One backend, four frontends, zero polling.

// Architecture — AG-UI event flow across SLOI AI
🤖
Claude API
Negotiation · LeadFinder · PriceScout
AG-UI Backend
Railway · Node.js · SSE endpoint
⚡ Admin
All sessions
👤 Buyer
Own deals
🌍 Trader
Assigned
🏭 Supplier
RFQ only
SSE stream Events: TEXT_CHUNK · ROUND_COMPLETE · AWAIT_HUMAN · LOI_GENERATED · PRICE_UPDATE · LEAD_FOUND · STATE_PATCH
// AG-UI EVENT TYPES — SLOI AI

All events are JSON objects streamed over SSE. S→C = server to client. C→S = client to server (via POST).

Negotiation
NEGOTIATION_STARTED
S→C
Negotiation session opened. Contains: neg_id, product, qty, buyer, strategy, max_rounds.
All portals with access to this deal
TEXT_CHUNK
S→C
Streaming token from Claude. Append to current bubble. Contains: text, role (agent|trader), round.
Agent Console · Buyer Platform (read-only)
ROUND_COMPLETE
S→C
A full negotiation round finished. Contains: round, agent_price, broker_price, gap_pct, next_action.
All portals
AWAIT_HUMAN
S→C
Agent stopped — Boss approval required. Contains: deal_price, total_value, loi_fee, credits_used. UI must show approval box.
Admin · Agent Console · Boss Telegram
HUMAN_RESPONSE
C→S
Boss decision. Contains: action (approve|reject|override), override_price (optional).
Admin · Agent Console · Telegram Bot
LOI_GENERATED
S→C
LOI created after approval. Contains: loi_ref, deal_price, total_value, pdf_url, buyer_email, broker_phone.
All portals
NEGOTIATION_FAILED
S→C
Max rounds reached without deal, or rejected. Contains: reason, last_agent_price, last_broker_price.
All portals
Prices & Leads
PRICE_UPDATE
S→C
Live price change for a commodity. Contains: sku, price, prev_price, change_pct, unit, timestamp.
Exchange · Buyer Platform · Agent Console PriceScout
PRICE_ALERT
S→C
A watched price hit its target. Contains: sku, product, target_price, current_price, user_id.
Buyer Platform · Admin · Telegram Bot
LEAD_FOUND
S→C
LeadFinder found a new opportunity. Contains: name, country, value, sector, relevance, reason.
Agent Console · Admin · Telegram Bot
System
STATE_PATCH
S→C
Partial state update (JSON Patch RFC 6902). Use to sync credit balance, session list, pipeline.
All portals
HEARTBEAT
S→C
Keep-alive every 15s. Contains: timestamp, active_sessions count.
All portals
COMPLIANCE_RESULT
S→C
Compliance screening result. Contains: entity, country, verdict (cleared|review|blocked), lists_matched.
Admin · Agent Console
// EVENT ENVELOPE — JSON FORMAT
JSON — every AG-UI event
{
  "type": "ROUND_COMPLETE",       // event type
  "ts": 1746518400000,          // Unix ms timestamp
  "session_id": "NEG-001",      // negotiation session ID
  "user_id": "usr_abc123",      // who this is for (RLS)
  "data": {                       // event-specific payload
    "round": 3,
    "agent_price": 2780,
    "broker_price": 2860,
    "gap_pct": 2.88,
    "next_action": "continue"
  }
}
// SSE ENDPOINT — POST /v1/negotiate
NODE.JS — Railway · routes/negotiate.js
const Anthropic = require('@anthropic-ai/sdk');
const anthropic = new Anthropic();

// POST /v1/negotiate — returns SSE stream
app.post('/v1/negotiate', authenticate, async (req, res) => {
  const { neg_id, product, qty, unit, target, max, buyer, strategy } = req.body;

  // Set SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering

  const emit = (type, data) => {
    res.write(`data: ${JSON.stringify({ type, ts: Date.now(), session_id: neg_id, data })}\n\n`);
  };

  const maxRounds = strategy === 'aggressive' ? 7 : 5;
  let history = [];
  let agentPrice = null, brokerPrice = null;

  emit('NEGOTIATION_STARTED', { neg_id, product, qty, unit, buyer, strategy, maxRounds });

  for (let round = 1; round <= maxRounds; round++) {

    // ── AGENT TURN (streaming) ─────────────────────
    const agentStream = await anthropic.messages.stream({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 300,
      system: buildAgentSystem(product, qty, unit, target, max, buyer, strategy, round, maxRounds),
      messages: [...history, { role: 'user', content: round === 1 ? 'Start. Make opening offer.' : 'Make your counter-offer.' }],
    });

    let agentMsg = '';
    for await (const chunk of agentStream) {
      if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
        agentMsg += chunk.delta.text;
        emit('TEXT_CHUNK', { text: chunk.delta.text, role: 'agent', round });
      }
    }

    agentPrice = extractPrice(agentMsg);
    history.push({ role: 'assistant', content: agentMsg });

    // ── BROKER TURN (streaming) ────────────────────
    const brokerHistory = history.map(m => ({ role: m.role === 'assistant' ? 'user' : 'assistant', content: m.content }));
    const brokerStream = await anthropic.messages.stream({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 300,
      system: buildBrokerSystem(product, target, max, round),
      messages: [...brokerHistory, { role: 'user', content: 'Respond to the offer.' }],
    });

    let brokerMsg = '';
    for await (const chunk of brokerStream) {
      if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
        brokerMsg += chunk.delta.text;
        emit('TEXT_CHUNK', { text: chunk.delta.text, role: 'trader', round });
      }
    }

    brokerPrice = extractPrice(brokerMsg);
    history.push({ role: 'user', content: brokerMsg });

    const gapPct = agentPrice && brokerPrice ? ((brokerPrice - agentPrice) / agentPrice * 100) : 99;
    emit('ROUND_COMPLETE', { round, agent_price: agentPrice, broker_price: brokerPrice, gap_pct: gapPct });

    // ── CHECK DEAL ────────────────────────────────
    if (agentPrice && brokerPrice && brokerPrice <= max && gapPct < 2.5) {
      const dealPrice = Math.round((agentPrice + brokerPrice) / 2);

      // AWAIT_HUMAN — stop and wait for Boss
      emit('AWAIT_HUMAN', {
        deal_price: dealPrice,
        total_value: dealPrice * qty,
        loi_fee: 500,
        credits_used: strategy === 'aggressive' ? 25 : 15,
        round,
      });

      // Wait for boss decision (stored in DB by approval endpoint)
      const decision = await waitForBossDecision(neg_id); // polls Supabase

      if (decision.action === 'approve') {
        const loiRef = await generateLOI({ neg_id, product, qty, unit, buyer, dealPrice });
        emit('LOI_GENERATED', { loi_ref: loiRef, deal_price: dealPrice, total_value: dealPrice * qty, pdf_url: `/loi/${loiRef}.pdf` });
      } else {
        emit('NEGOTIATION_FAILED', { reason: 'boss_rejected' });
      }
      break;
    }

    if (round === maxRounds) {
      emit('NEGOTIATION_FAILED', { reason: 'max_rounds', last_agent_price: agentPrice, last_broker_price: brokerPrice });
    }
  }

  res.end();
});

// Boss approval endpoint
app.post('/v1/negotiations/:id/approve', authenticate, requireBoss, async (req, res) => {
  const { action, override_price } = req.body;
  await supabase.from('negotiations').update({ boss_decision: action, override_price }).eq('id', req.params.id);
  res.json({ ok: true });
});
// GLOBAL SSE STREAM — GET /v1/stream

All portals connect to a single personal stream filtered by role/user_id. One connection, all events.

NODE.JS — Global event stream with RLS filtering
// GET /v1/stream — persistent SSE connection per authenticated user
app.get('/v1/stream', authenticate, (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');

  const userId = req.caller.id;
  const role = req.caller.role; // boss|buyer|trader|supplier

  // Register this connection in event bus
  const unsubscribe = eventBus.subscribe(userId, role, (event) => {
    res.write(`data: ${JSON.stringify(event)}\n\n`);
  });

  // Heartbeat every 15s
  const hb = setInterval(() => {
    res.write(`data: ${JSON.stringify({ type: 'HEARTBEAT', ts: Date.now() })}\n\n`);
  }, 15000);

  req.on('close', () => { unsubscribe(); clearInterval(hb); });
});

// Event bus — pub/sub with role-based routing
const eventBus = {
  subscribers: new Map(),

  subscribe(userId, role, cb) {
    this.subscribers.set(userId, { role, cb });
    return () => this.subscribers.delete(userId);
  },

  publish(event, targetRoles) {
    this.subscribers.forEach(({ role, cb }, userId) => {
      // Boss sees everything. Others see their own events only.
      if (role === 'boss' || targetRoles.includes(role) || event.user_id === userId) {
        cb(event);
      }
    });
  }
};
// SUPABASE — NEGOTIATIONS TABLE
SQL — Supabase schema
CREATE TABLE negotiations (
  id              TEXT PRIMARY KEY,         -- NEG-001
  product         TEXT NOT NULL,
  qty             INT NOT NULL,
  unit            TEXT DEFAULT 'MT',
  buyer_id        UUID REFERENCES users(id),
  buyer_name      TEXT,
  broker_id       UUID REFERENCES users(id),
  target_price    NUMERIC(12,2),
  max_price       NUMERIC(12,2),
  strategy        TEXT DEFAULT 'standard',
  status          TEXT DEFAULT 'running',  -- running|awaiting_boss|completed|failed
  current_round   INT DEFAULT 0,
  max_rounds      INT DEFAULT 5,
  agent_price     NUMERIC(12,2),
  broker_price    NUMERIC(12,2),
  deal_price      NUMERIC(12,2),
  boss_decision   TEXT,                      -- approve|reject
  override_price  NUMERIC(12,2),
  loi_ref         TEXT,
  credits_used    INT DEFAULT 0,
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  updated_at      TIMESTAMPTZ DEFAULT NOW()
);

-- RLS: buyers see own deals, traders see assigned, boss sees all
ALTER TABLE negotiations ENABLE ROW LEVEL SECURITY;
CREATE POLICY buyer_own ON negotiations FOR SELECT USING (buyer_id = auth.uid());
CREATE POLICY broker_assigned ON negotiations FOR SELECT USING (broker_id = auth.uid());
CREATE POLICY boss_all ON negotiations FOR ALL USING (is_boss(auth.uid()));
// AG-UI CLIENT — shared across all portals

One JS class. Each portal imports it and registers handlers for the events it cares about.

JAVASCRIPT — agui-client.js (shared)
// agui-client.js — include in every portal
class SloiAGUI {
  constructor(authToken) {
    this.token = authToken;
    this.handlers = {};
    this.es = null;
    this.reconnectDelay = 1000;
  }

  on(eventType, handler) {
    if (!this.handlers[eventType]) this.handlers[eventType] = [];
    this.handlers[eventType].push(handler);
    return this; // chainable
  }

  connect() {
    this.es = new EventSource(`/v1/stream?token=${this.token}`);

    this.es.onmessage = (e) => {
      try {
        const event = JSON.parse(e.data);
        const handlers = this.handlers[event.type] || [];
        handlers.forEach(h => h(event.data, event));
      } catch(err) { console.error('AG-UI parse error', err); }
    };

    this.es.onerror = () => {
      this.es.close();
      setTimeout(() => this.connect(), this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000));
    };

    this.es.onopen = () => { this.reconnectDelay = 1000; };
    return this;
  }

  async humanResponse(negId, action, overridePrice = null) {
    return fetch(`/v1/negotiations/${negId}/approve`, {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ action, override_price: overridePrice }),
    });
  }

  disconnect() { if (this.es) this.es.close(); }
}

// Export for use in each portal
window.SloiAGUI = SloiAGUI;
// USAGE IN AGENT CONSOLE
JAVASCRIPT — Agent Console (negotiation-agent.html)
const agui = new SloiAGUI(AUTH_TOKEN)
  .on('TEXT_CHUNK', ({ text, role, round }) => {
    // Stream tokens into the correct bubble
    let bubble = document.getElementById('bubble-r' + round + '-' + role);
    if (!bubble) bubble = addBubble(role, '', role.toUpperCase() + ' Round ' + round);
    bubble.innerHTML += text;
    chatArea.scrollTop = chatArea.scrollHeight;
  })
  .on('ROUND_COMPLETE', ({ round, agent_price, broker_price, gap_pct }) => {
    updateRoundsBar(round, maxRounds);
    updatePriceDisplay(agent_price, broker_price);
    addBubble('system', `Round ${round} complete · Gap: ${gap_pct.toFixed(1)}%`);
  })
  .on('AWAIT_HUMAN', ({ deal_price, total_value, loi_fee, credits_used }) => {
    showApprovalBox(deal_price, total_value, loi_fee, credits_used);
    // Boss clicks → agui.humanResponse(negId, 'approve')
  })
  .on('LOI_GENERATED', ({ loi_ref, deal_price, pdf_url }) => {
    addBubble('loi', `✅ LOI ${loi_ref} · $${deal_price.toLocaleString()} · `
      + `📄 Download`);
  })
  .on('LEAD_FOUND', ({ name, country, value, relevance, reason }) => {
    appendLeadCard({ name, country, value, relevance, reason });
    document.getElementById('tc-lead').textContent = ++leadCount + ' new';
  })
  .on('PRICE_UPDATE', ({ sku, price, change_pct }) => {
    updatePriceRow(sku, price, change_pct);
  })
  .connect();
// EVENTS PER PORTAL — what each one listens to
Boss Admin
Sees everything · Full control
All NEGOTIATION_* events · all sessions
AWAIT_HUMAN → shows approval popup
LOI_GENERATED → updates revenue counter
LEAD_FOUND → adds to pipeline
PRICE_UPDATE → updates war room
COMPLIANCE_RESULT → updates log
STATE_PATCH → credit balance, counters
👤
Buyer Platform
Own deals only · Read-only stream
ROUND_COMPLETE → updates status badge
AWAIT_HUMAN → "Pending boss approval" UI
LOI_GENERATED → shows download link
PRICE_UPDATE → Exchange live prices
PRICE_ALERT → toast notification
STATE_PATCH → credit balance
🌍
Trader Portal
Assigned deals only
NEGOTIATION_STARTED → new RFQ alert
ROUND_COMPLETE → round status
LOI_GENERATED → LOI notification
PRICE_UPDATE → market prices
STATE_PATCH → revenue tracker
🏭
Supplier Portal
RFQ and LOI events only
NEGOTIATION_STARTED → new RFQ badge
LOI_GENERATED → LOI to acknowledge
PRICE_UPDATE → own product prices
No deal prices, no agent messages
// AWAIT_HUMAN IN ADMIN — inline approval
JAVASCRIPT — Admin War Room (CommodEx_Admin.html)
agui.on('AWAIT_HUMAN', (data, event) => {
  // Show approval popup in War Room — top priority
  const popup = document.getElementById('approval-popup');
  popup.innerHTML = `
    <div class="approval-overlay">
      <div class="approval-box">
        <div class="ab-title">⚡ Boss approval required</div>
        <div class="ab-neg">${event.session_id}</div>
        <div class="ab-price">$${data.deal_price.toLocaleString()}/MT</div>
        <div class="ab-total">Total: $${data.total_value.toLocaleString()}</div>
        <div class="ab-actions">
          <button onclick="agui.humanResponse('${event.session_id}','approve')">✅ Approve</button>
          <button onclick="agui.humanResponse('${event.session_id}','reject')">✗ Reject</button>
        </div>
      </div>
    </div>`;
  popup.style.display = 'block';

  // Also notify via Telegram Bot
  fetch('/v1/telegram-bot/notify', { method: 'POST', body: JSON.stringify({ type: 'AWAIT_HUMAN', ...data }) });
});

// Hide popup after decision
agui.on('LOI_GENERATED', () => {
  document.getElementById('approval-popup').style.display = 'none';
  incrementLOICounter();
});
// BUYER PLATFORM — read-only negotiation status
JAVASCRIPT — Buyer Platform (CommodEx_Platform.html)
agui
  .on('ROUND_COMPLETE', ({ round, agent_price, broker_price }) => {
    // Update negotiate tab status — buyer sees round progress
    document.getElementById('neg-status').textContent = `Round ${round} · Agent: $${agent_price?.toLocaleString()} · Supplier: $${broker_price?.toLocaleString()}`;
    document.getElementById('neg-badge').textContent = `● R${round}`;
  })
  .on('AWAIT_HUMAN', ({ deal_price, total_value }) => {
    // Buyer sees "Pending boss approval" — cannot approve themselves
    document.getElementById('neg-status').innerHTML =
      `⏳ Pending boss approval · Deal at $${deal_price.toLocaleString()}/MT`;
  })
  .on('LOI_GENERATED', ({ loi_ref, pdf_url }) => {
    // Switch to LOIs tab and show download
    show('lois');
    prependLOI({ ref: loi_ref, pdf_url });
    showToast('✅ LOI generated! ' + loi_ref);
  })
  .on('PRICE_UPDATE', ({ sku, price, change_pct }) => {
    exUpdatePrice(sku, price, change_pct); // update Exchange tab
  })
  .connect();
// SETUP CHECKLIST — developer
Backend (Railway)
Install Anthropic SDK: npm install @anthropic-ai/sdk
Phase 1
Create routes/negotiate.js with SSE endpoint + streaming Claude calls
Phase 1
Create routes/stream.js — global SSE stream with eventBus + role filtering
Phase 1
Add POST /v1/negotiations/:id/approve — writes boss decision to Supabase
Phase 1
Create Supabase negotiations table with RLS policies
Phase 1
Add waitForBossDecision(negId) — polls Supabase realtime subscription
Phase 1
Set X-Accel-Buffering: no header on Railway (disables nginx buffering for SSE)
Required
Frontend (Vercel)
Create agui-client.js — shared SloiAGUI class
Phase 2
Import agui-client.js in all 4 portals: Admin, Buyer, Trader, Supplier
Phase 2
Replace direct Claude API calls in Agent Console with backend SSE stream
Phase 2
Add AWAIT_HUMAN approval popup to Admin War Room
Phase 2
Add read-only negotiation status to Buyer Platform Negotiate tab
Phase 2
Telegram Bot integration
Add POST /v1/telegram-bot/notify — forwards AWAIT_HUMAN to Telegram Bot webhook
Phase 3
Telegram Bot "approve" command → calls POST /v1/negotiations/:id/approve
Phase 3
Add TELEGRAM_BOT_TOKEN to Railway ENV
Phase 3
// ENV VARIABLES — add to Railway
ENV
ANTHROPIC_API_KEY=sk-ant-xxxx          # Claude API — streaming enabled
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_KEY=eyJh...
TELEGRAM_BOT_TOKEN=https://...       # from Telegram Bot VPS setup
AG_UI_HEARTBEAT_INTERVAL=15000         # ms between heartbeats
Result: one stream.
Four portals. Real time.

Boss approves on Telegram → Admin popup closes → Buyer sees LOI → Trader gets notification → all from a single SSE stream.

Telegram Bot setup → Payment infra → Full API docs →