Building a Human AI Agent Workspace: Flutter + Node.js Blueprint
Building a human ai agent workspace with Flutter and Node.js for real-time task negotiation. How to integrate WebSockets for true human-agent collaboration.
Umair · Flutter & AI Engineer
June 22, 2026 · 11 min read
Everyone's talking about AI agents automating everything. But honestly, most of those discussions skip the hardest part: making agents work with humans, not just for them. I've shipped 20+ apps and built AI systems from FarahGPT to a 9-agent YouTube pipeline. The real grind isn't just spinning up agents, it's building a human ai agent workspace where they become part of a collaborative team, not just black boxes. Figured out the hard way that a true collaboration needs real-time task negotiation and intervention, not just fire-and-forget prompts.
Why a Dedicated Human AI Agent Workspace is Non-Negotiable
My journey building FarahGPT, an AI gold trading system with 5,100+ users, taught me a critical lesson: pure agent autonomy is often overrated, and sometimes dangerous. Especially when money or critical decisions are involved. I built NexusOS for AI agent governance, but governance is one thing; daily workflow is another. You need a space where humans and agents share context, negotiate tasks, and intervene when needed. That's what a true ai agent collaboration platform needs.
Here’s why it matters:
- Real-time Task Negotiation: Agents propose, humans review/adjust, agents execute. It's a conversation, not a command.
- Intervention Points: Humans must be able to pause, override, or redirect an agent's task at any moment. This is crucial for safety and alignment.
- Shared Context & State: Both human and agent need to see the same evolving task board, the same project files, the same chat history.
- Auditable Decision Paths: When an agent makes a call, you need to know why. The workspace needs to log every interaction, every decision, every human override.
Without this, agents remain siloed tools. We're talking about building a team, not just deploying bots.
The AgentSpace Blueprint: Flutter UI, Node.js Backend
To build this next-gen human ai agent workspace, I went with a Flutter frontend and a Node.js backend. Why?
- Flutter: Cross-platform UI (web, desktop, mobile) from a single codebase. It's fast, reactive, and lets you build complex UIs like an "agent canvas" with ease. Perfect for a multi agent system ui.
- Node.js: Asynchronous, event-driven backend. Ideal for handling thousands of concurrent WebSocket connections for real-time updates and orchestrating multiple AI agents. It integrates nicely with various AI APIs (OpenAI, Claude API) and databases (MongoDB, Supabase).
Here’s the high-level architecture:
- Flutter Client: The "Agent Canvas" where humans interact.
- Node.js Backend:
- WebSockets: For real-time communication between Flutter clients and agents.
- REST API: For initial data fetching, authentication, and less time-sensitive operations.
- Agent Orchestrator: Manages communication with various AI models (Claude, OpenAI) and dispatches tasks.
- Database (MongoDB): Stores task states, agent configurations, user data, and interaction logs.
- AI Agents: External services (or internal Node.js modules) powered by LLMs, performing the actual tasks.
The core of this blueprint is the real-time interaction enabled by WebSockets for human-agent task negotiation.
Building the Real-Time Agent Canvas & Task Negotiation
This is where the rubber meets the road. We need a way for humans to assign tasks, agents to propose solutions, humans to review, and agents to act—all in real time.
Node.js Backend: The Real-Time Engine with WebSockets
I use the ws library for Node.js. It's fast, efficient, and lets you handle a ton of concurrent connections. The key is setting it up right, especially if you're deploying behind a cloud load balancer like Google Cloud Load Balancer (GCLB) or AWS ALB.
Here's a simplified example of the Node.js WebSocket server:
// server.js
const WebSocket = require('ws');
const express = require('express');
const http = require('http');
const { v4: uuidv4 } = require('uuid');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({
server,
// CRITICAL: This is the undocumented part.
// maxPayload defaults to 1048576 (1MB). If you send messages larger than this,
// the 'ws' library *will* silently drop them or error out with 'Invalid WebSocket frame'.
// BUT, if you have perMessageDeflate enabled AND you are behind certain cloud LBs (like GCLB),
// GCLB often has its own *internal* payload limits or weird behaviors with compressed frames
// that are NOT well-documented. If you start seeing 'Error: Invalid WebSocket frame: data and RSV1 are both set'
// or unexplained disconnects for larger messages, even if maxPayload is high,
// try setting perMessageDeflate to false or carefully managing message size.
// For many enterprise setups, I've had to explicitly set `perMessageDeflate: false` to avoid
// obscure issues with intermediate proxies or load balancers that don't correctly
// handle compressed WebSocket frames or have stricter, undocumented payload limits post-compression.
perMessageDeflate: false // Set to false to avoid issues with some proxies/LBs if you don't absolutely need compression.
});
const clients = new Map(); // Store connected clients by ID
const agents = new Map(); // Store active agents by ID
// Example Agent Orchestrator (simplified)
class AgentOrchestrator {
async assignTask(task, agentId, humanClientId) {
console.log(`Orchestrator: Assigning task "${task.description}" to agent ${agentId}`);
const agent = agents.get(agentId);
if (agent && agent.ws.readyState === WebSocket.OPEN) {
// Simulate agent processing and proposing a plan
const plan = await this.simulateAgentResponse(task);
const negotiationMessage = {
type: 'AGENT_PROPOSAL',
taskId: task.id,
agentId: agentId,
humanClientId: humanClientId,
proposal: plan,
status: 'PENDING_HUMAN_REVIEW'
};
agent.ws.send(JSON.stringify(negotiationMessage));
console.log(`Orchestrator: Agent ${agentId} proposed plan for task ${task.id}.`);
} else {
console.error(`Orchestrator: Agent ${agentId} not found or not connected.`);
}
}
async executeTask(task, agentId, humanClientId, approvedPlan) {
console.log(`Orchestrator: Agent ${agentId} executing task "${task.description}" with plan: ${approvedPlan}`);
const agent = agents.get(agentId);
if (agent && agent.ws.readyState === WebSocket.OPEN) {
// Simulate actual execution by the agent (e.g., calling Claude API)
const result = await this.simulateAgentExecution(task, approvedPlan);
const completionMessage = {
type: 'TASK_COMPLETED',
taskId: task.id,
agentId: agentId,
humanClientId: humanClientId,
result: result,
status: 'COMPLETED'
};
agent.ws.send(JSON.stringify(completionMessage));
// Also notify the human client
const humanClient = clients.get(humanClientId);
if(humanClient && humanClient.ws.readyState === WebSocket.OPEN) {
humanClient.ws.send(JSON.stringify(completionMessage));
}
console.log(`Orchestrator: Agent ${agentId} completed task ${task.id}.`);
} else {
console.error(`Orchestrator: Agent ${agentId} not found or not connected.`);
}
}
// Dummy agent response simulation
simulateAgentResponse(task) {
return new Promise(resolve => {
setTimeout(() => {
resolve({
steps: [
`Analyze market data for "${task.description}"`,
`Formulate a trading strategy based on AI models`,
`Propose buy/sell order with target profit/loss`
],
confidence: 'high',
riskLevel: 'medium'
});
}, 1500);
});
}
// Dummy agent execution simulation
simulateAgentExecution(task, plan) {
return new Promise(resolve => {
setTimeout(() => {
resolve({
finalOutcome: 'Simulated profit of 0.5% on gold futures.',
executionLogs: ['Step 1 complete', 'Step 2 complete', 'Order placed']
});
}, 3000);
});
}
}
const orchestrator = new AgentOrchestrator();
wss.on('connection', ws => {
const clientId = uuidv4();
clients.set(clientId, { ws, type: 'human' }); // Default to human, can be registered as agent later
console.log(`Client connected: ${clientId}`);
ws.send(JSON.stringify({ type: 'CONNECTED', clientId }));
ws.on('message', async message => {
try {
const data = JSON.parse(message.toString());
console.log(`Received message from ${clientId}:`, data);
switch (data.type) {
case 'REGISTER_AGENT':
// This client is an agent, not a human UI
clients.get(clientId).type = 'agent';
agents.set(clientId, { ws, id: clientId });
console.log(`Agent registered: ${clientId}`);
break;
case 'ASSIGN_TASK':
// Human client assigns a task to a specific agent
const taskId = uuidv4();
const task = { id: taskId, description: data.description, assignedTo: data.agentId, humanClientId: clientId };
await orchestrator.assignTask(task, data.agentId, clientId);
// Notify the human that task has been sent for proposal
ws.send(JSON.stringify({
type: 'TASK_ASSIGNED',
taskId: taskId,
description: data.description,
status: 'WAITING_AGENT_PROPOSAL'
}));
break;
case 'APPROVE_PROPOSAL':
// Human client approves agent's proposal
const approvedTask = { id: data.taskId, description: data.description };
await orchestrator.executeTask(approvedTask, data.agentId, clientId, data.approvedPlan);
break;
case 'REJECT_PROPOSAL':
// Human client rejects agent's proposal, sends back for revision or re-assignment
console.log(`Human ${clientId} rejected proposal for task ${data.taskId}.`);
// Logic to inform the agent, or re-assign
const agent = agents.get(data.agentId);
if (agent && agent.ws.readyState === WebSocket.OPEN) {
agent.ws.send(JSON.stringify({
type: 'PROPOSAL_REJECTED',
taskId: data.taskId,
reason: data.reason || 'Human review rejected proposal.'
}));
}
break;
case 'INTERVENE_TASK':
// Human intervenes in an ongoing task
console.log(`Human ${clientId} intervened in task ${data.taskId} with action: ${data.action}`);
// Logic to pause, modify, or cancel agent activity
break;
// ... handle other message types (e.g., status updates, agent errors)
}
} catch (error) {
console.error(`Failed to parse or handle message from ${clientId}:`, message.toString(), error);
}
});
ws.on('close', () => {
console.log(`Client disconnected: ${clientId}`);
clients.delete(clientId);
agents.delete(clientId); // If it was an agent
});
ws.on('error', error => {
console.error(`WebSocket error for client ${clientId}:`, error);
});
});
// Basic REST endpoint for health check or initial data
app.get('/health', (req, res) => {
res.json({ status: 'ok', clients: clients.size, agents: agents.size });
});
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(`Node.js WebSocket server listening on port ${PORT}`);
});
That perMessageDeflate: false line isn't in most quick-start ws docs, but it's saved me hours of debugging weird Error: Invalid WebSocket frame: data and RSV1 are both set issues when deploying to cloud environments. Many load balancers and proxies don't play nice with the default compressed frames, especially if message sizes are large or fluctuate. Sometimes disabling compression entirely is the most reliable path.
Flutter UI: The Agent Canvas
On the Flutter side, we use the web_socket_channel package. The UI needs to display active agents, their assigned tasks, current status, and provide controls for human interaction.
// main.dart (simplified for agent canvas example)
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:uuid/uuid.dart';
import 'dart:convert';
void main() => runApp(const AgentSpaceApp());
class AgentSpaceApp extends StatelessWidget {
const AgentSpaceApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AgentSpace',
theme: ThemeData(
primarySwatch: Colors.blueGrey,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const AgentCanvasScreen(),
);
}
}
class AgentCanvasScreen extends StatefulWidget {
const AgentCanvasScreen({super.key});
@override
State<AgentCanvasScreen> createState() => _AgentCanvasScreenState();
}
class _AgentCanvasScreenState extends State<AgentCanvasScreen> {
final _channel = WebSocketChannel.connect(Uri.parse('ws://localhost:8080')); // Adjust for your Node.js server
final TextEditingController _taskController = TextEditingController();
final TextEditingController _agentIdController = TextEditingController(text: 'agent-123'); // Example agent ID
String _clientId = 'Connecting...';
final List<AgentTask> _tasks = [];
final Uuid _uuid = const Uuid();
@override
void initState() {
super.initState();
_channel.stream.listen((message) {
final data = jsonDecode(message.toString());
_handleWebSocketMessage(data);
}, onError: (error) {
print('WebSocket Error: $error');
setState(() {
_clientId = 'Disconnected. Error: $error';
});
}, onDone: () {
print('WebSocket Done');
setState(() {
_clientId = 'Disconnected.';
});
});
}
void _handleWebSocketMessage(Map<String, dynamic> data) {
setState(() {
switch (data['type']) {
case 'CONNECTED':
_clientId = data['clientId'];
break;
case 'TASK_ASSIGNED':
_tasks.add(AgentTask(
id: data['taskId'],
description: data['description'],
status: data['status'],
agentId: _agentIdController.text,
humanClientId: _clientId,
));
break;
case 'AGENT_PROPOSAL':
final index = _tasks.indexWhere((task) => task.id == data['taskId']);
if (index != -1) {
_tasks[index].status = data['status'];
_tasks[index].proposal = data['proposal'];
}
break;
case 'TASK_COMPLETED':
final index = _tasks.indexWhere((task) => task.id == data['taskId']);
if (index != -1) {
_tasks[index].status = data['status'];
_tasks[index].result = data['result'];
}
break;
case 'PROPOSAL_REJECTED':
final index = _tasks.indexWhere((task) => task.id == data['taskId']);
if (index != -1) {
_tasks[index].status = 'PROPOSAL_REJECTED';
_tasks[index].reason = data['reason'];
}
break;
default:
print('Unknown message type: ${data['type']}');
}
});
}
void _assignTask() {
if (_taskController.text.isNotEmpty && _agentIdController.text.isNotEmpty) {
_channel.sink.add(jsonEncode({
'type': 'ASSIGN_TASK',
'description': _taskController.text,
'agentId': _agentIdController.text,
}));
_taskController.clear();
}
}
void _approveProposal(AgentTask task) {
_channel.sink.add(jsonEncode({
'type': 'APPROVE_PROPOSAL',
'taskId': task.id,
'agentId': task.agentId,
'approvedPlan': task.proposal, // Sending the entire proposal back
}));
}
void _rejectProposal(AgentTask task) {
_channel.sink.add(jsonEncode({
'type': 'REJECT_PROPOSAL',
'taskId': task.id,
'agentId': task.agentId,
'reason': 'Plan requires revision or is unsuitable.',
}));
}
void _interveneTask(AgentTask task, String action) {
_channel.sink.add(jsonEncode({
'type': 'INTERVENE_TASK',
'taskId': task.id,
'agentId': task.agentId,
'action': action, // e.g., 'PAUSE', 'CANCEL', 'MODIFY'
}));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('AgentSpace: Human-Agent Collaboration'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Your Client ID: $_clientId', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextField(
controller: _agentIdController,
decoration: const InputDecoration(
labelText: 'Target Agent ID (e.g., agent-123)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _taskController,
decoration: const InputDecoration(
labelText: 'Assign a new task to agent',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _assignTask(),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _assignTask,
child: const Text('Assign Task'),
),
],
),
const SizedBox(height: 24),
Text('Active Tasks & Agent Canvas:', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: _tasks.length,
itemBuilder: (context, index) {
final task = _tasks[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Task: ${task.description}', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text('Agent: ${task.agentId} | Status: ${task.status}', style: const TextStyle(fontStyle: FontStyle.italic)),
if (task.status == 'PENDING_HUMAN_REVIEW' && task.proposal != null) ...[
const SizedBox(height: 8),
Text('Agent Proposal:', style: Theme.of(context).textTheme.bodyLarge),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(jsonEncode(task.proposal), style: const TextStyle(fontFamily: 'monospace')),
),
const SizedBox(height: 8),
Row(
children: [
ElevatedButton(
onPressed: () => _approveProposal(task),
child: const Text('Approve'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _rejectProposal(task),
child: const Text('Reject'),
),
],
),
],
if (task.status == 'COMPLETED' && task.result != null) ...[
const SizedBox(height: 8),
Text('Result:', style: Theme.of(context).textTheme.bodyLarge),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(jsonEncode(task.result), style: const TextStyle(fontFamily: 'monospace')),
),
],
if (task.status == 'PROPOSAL_REJECTED' && task.reason != null) ...[
const SizedBox(height: 8),
Text('Rejection Reason: ${task.reason}', style: const TextStyle(color: Colors.red)),
],
if (task.status != 'COMPLETED' && task.status != 'PENDING_HUMAN_REVIEW') ...[
const SizedBox(height: 8),
Row(
children: [
OutlinedButton(
onPressed: () => _interveneTask(task, 'PAUSE'),
child: const Text('Pause'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _interveneTask(task, 'CANCEL'),
child: const Text('Cancel'),
),
],
),
],
],
),
),
);
},
),
),
],
),
),
);
}
@override
void dispose() {
_channel.sink.close();
_taskController.dispose();
_agentIdController.dispose();
super.dispose();
}
}
class AgentTask {
final String id;
final String description;
String status;
final String agentId;
final String humanClientId;
Map<String, dynamic>? proposal;
Map<String, dynamic>? result;
String? reason;
AgentTask({
required this.id,
required this.description,
required this.status,
required this.agentId,
required this.humanClientId,
this.proposal,
this.result,
this.reason,
});
}
This Flutter prototype gives you a real-time feed of
Need a Flutter developer?
I build production apps from scratch — iOS, Android, AI features, payments. Fixed price, App Store guaranteed.
Get a Free Proposal →Related Posts
How I Built an AI Agent Video Voiceover Pipeline: Node.js + Flutter
Stop manual video content. Build an AI agent video voiceover system with Node.js orchestration and Flutter, complete with error handling and real LLM benchma...
AI Chat Data Privacy: Heppner Ruling & Your App
The US v. Heppner ruling changes AI chat data privacy. Learn practical steps to protect client data in your AI app. Umair's take.
Node.js AI Agents Backend: What Actually Works at Scale
Building a scalable Node.js AI agents backend for Flutter apps is tough. I learned what fails at scale and how to fix it, shipping 20+ apps.