diff --git a/src/AmtocBots.Api/AmtocBots.Api.csproj b/src/AmtocBots.Api/AmtocBots.Api.csproj
new file mode 100644
index 0000000..1b4d191
--- /dev/null
+++ b/src/AmtocBots.Api/AmtocBots.Api.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net10.0
+ enable
+ enable
+ AmtocBots.Api
+ AmtocBots.Api
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AmtocBots.Api/BackgroundServices/MetricsPollingService.cs b/src/AmtocBots.Api/BackgroundServices/MetricsPollingService.cs
new file mode 100644
index 0000000..0d2ae2f
--- /dev/null
+++ b/src/AmtocBots.Api/BackgroundServices/MetricsPollingService.cs
@@ -0,0 +1,72 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Hubs;
+using AmtocBots.Api.Services.Docker;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.BackgroundServices;
+
+public sealed class MetricsPollingService(
+ IServiceScopeFactory scopeFactory,
+ IHubContext hub,
+ IDockerService docker,
+ ILogger log) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ log.LogInformation("Metrics polling service started");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await PollAndBroadcastAsync(stoppingToken);
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ log.LogWarning(ex, "Error during metrics polling");
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
+ }
+ }
+
+ private async Task PollAndBroadcastAsync(CancellationToken ct)
+ {
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var running = await db.Instances
+ .Where(i => i.ContainerId != null && i.Status == "running")
+ .Select(i => new { i.Id, ContainerId = i.ContainerId! })
+ .ToListAsync(ct);
+
+ if (running.Count == 0) return;
+
+ var stats = await docker.GetAllManagedStatsAsync(
+ running.Select(r => (r.Id, r.ContainerId)), ct);
+
+ foreach (var stat in stats)
+ {
+ await hub.Clients.Group($"instance:{stat.InstanceId}")
+ .SendAsync("StatusUpdate", stat, ct);
+ }
+
+ // Sync status changes back to DB
+ var statusMap = stats.ToDictionary(s => s.InstanceId, s => s.Status);
+ foreach (var inst in running)
+ {
+ if (statusMap.TryGetValue(inst.Id, out var newStatus))
+ {
+ var entity = await db.Instances.FindAsync([inst.Id], ct);
+ if (entity is not null && entity.Status != newStatus)
+ {
+ entity.Status = newStatus;
+ entity.UpdatedAt = DateTimeOffset.UtcNow;
+ }
+ }
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/AmtocBots.Api/BackgroundServices/ModelSwitchScheduler.cs b/src/AmtocBots.Api/BackgroundServices/ModelSwitchScheduler.cs
new file mode 100644
index 0000000..02efa66
--- /dev/null
+++ b/src/AmtocBots.Api/BackgroundServices/ModelSwitchScheduler.cs
@@ -0,0 +1,69 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Services.Models;
+using Microsoft.EntityFrameworkCore;
+using NCrontab;
+
+namespace AmtocBots.Api.BackgroundServices;
+
+public sealed class ModelSwitchScheduler(
+ IServiceScopeFactory scopeFactory,
+ ILogger log) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ log.LogInformation("Model switch scheduler started");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await EvaluateAsync(stoppingToken);
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ log.LogWarning(ex, "Error in model switch scheduler");
+ }
+
+ await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
+ }
+ }
+
+ private async Task EvaluateAsync(CancellationToken ct)
+ {
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var switcher = scope.ServiceProvider.GetRequiredService();
+
+ var now = DateTime.UtcNow;
+ var cronRules = await db.SwitchRules
+ .Where(r => r.IsActive && r.RuleType == "cron" && r.CronExpression != null)
+ .ToListAsync(ct);
+
+ foreach (var rule in cronRules)
+ {
+ try
+ {
+ var schedule = CrontabSchedule.Parse(rule.CronExpression!, new CrontabSchedule.ParseOptions { IncludingSeconds = false });
+ var next = schedule.GetNextOccurrence(now.AddMinutes(-1));
+ if (next <= now)
+ {
+ log.LogInformation("Cron rule triggered for instance {Id}: switch to {Model}", rule.InstanceId, rule.TargetModel);
+ await switcher.SwitchModelAsync(rule.InstanceId, rule.TargetModel, ct);
+ }
+ }
+ catch (Exception ex)
+ {
+ log.LogWarning(ex, "Failed to evaluate cron rule {Id}", rule.Id);
+ }
+ }
+
+ // Also evaluate threshold rules for all running instances
+ var runningIds = await db.Instances
+ .Where(i => i.Status == "running")
+ .Select(i => i.Id)
+ .ToListAsync(ct);
+
+ foreach (var id in runningIds)
+ await switcher.EvaluateThresholdRulesAsync(id, ct);
+ }
+}
diff --git a/src/AmtocBots.Api/BackgroundServices/QueueRetryWorker.cs b/src/AmtocBots.Api/BackgroundServices/QueueRetryWorker.cs
new file mode 100644
index 0000000..789d314
--- /dev/null
+++ b/src/AmtocBots.Api/BackgroundServices/QueueRetryWorker.cs
@@ -0,0 +1,56 @@
+using AmtocBots.Api.Services.OpenClaw;
+using AmtocBots.Api.Services.Queue;
+
+namespace AmtocBots.Api.BackgroundServices;
+
+public sealed class QueueRetryWorker(
+ RedisMessageQueueService queue,
+ IOpenClawClient openClaw,
+ ILogger log) : BackgroundService
+{
+ private static readonly TimeSpan[] Backoffs = [
+ TimeSpan.FromSeconds(5),
+ TimeSpan.FromSeconds(15),
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromMinutes(2),
+ TimeSpan.FromMinutes(5),
+ ];
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ log.LogInformation("Queue retry worker started");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ // Move any ready delayed messages back to main queue
+ await queue.FlushReadyDelayedAsync(stoppingToken);
+
+ var msg = await queue.DequeueAsync(stoppingToken);
+ if (msg is null)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
+ continue;
+ }
+
+ try
+ {
+ await openClaw.RunAgentAsync(msg.BaseUrl, msg.BearerToken,
+ new AgentRequest(msg.Description, msg.Model), stoppingToken);
+ log.LogDebug("Retried queued message for instance {Id}", msg.InstanceId);
+ }
+ catch (HttpRequestException ex) when ((int?)ex.StatusCode == 429)
+ {
+ var delay = msg.RetryCount < Backoffs.Length
+ ? Backoffs[msg.RetryCount]
+ : TimeSpan.FromMinutes(10);
+
+ log.LogWarning("Rate limited on instance {Id}, requeuing with {Delay}s delay", msg.InstanceId, delay.TotalSeconds);
+ await queue.RequeueWithDelayAsync(msg, delay, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ log.LogError(ex, "Failed to send queued message for instance {Id}", msg.InstanceId);
+ }
+ }
+ }
+}
diff --git a/src/AmtocBots.Api/Configuration/DockerOptions.cs b/src/AmtocBots.Api/Configuration/DockerOptions.cs
new file mode 100644
index 0000000..0ad51bf
--- /dev/null
+++ b/src/AmtocBots.Api/Configuration/DockerOptions.cs
@@ -0,0 +1,10 @@
+namespace AmtocBots.Api.Configuration;
+
+public sealed class DockerOptions
+{
+ public string SocketPath { get; set; } = "/var/run/docker.sock";
+ public string OpenClawNetwork { get; set; } = "amtocbots_openclaw";
+ public string OpenClawImage { get; set; } = "ghcr.io/openclaw/openclaw:latest";
+ public int PortRangeStart { get; set; } = 18789;
+ public int PortRangeEnd { get; set; } = 19789;
+}
diff --git a/src/AmtocBots.Api/Configuration/EncryptionOptions.cs b/src/AmtocBots.Api/Configuration/EncryptionOptions.cs
new file mode 100644
index 0000000..a43f0f5
--- /dev/null
+++ b/src/AmtocBots.Api/Configuration/EncryptionOptions.cs
@@ -0,0 +1,7 @@
+namespace AmtocBots.Api.Configuration;
+
+public sealed class EncryptionOptions
+{
+ /// Base64-encoded 32-byte AES-256 key. Generate with: openssl rand -base64 32
+ public string Key { get; set; } = string.Empty;
+}
diff --git a/src/AmtocBots.Api/Configuration/KeycloakOptions.cs b/src/AmtocBots.Api/Configuration/KeycloakOptions.cs
new file mode 100644
index 0000000..891cc5a
--- /dev/null
+++ b/src/AmtocBots.Api/Configuration/KeycloakOptions.cs
@@ -0,0 +1,7 @@
+namespace AmtocBots.Api.Configuration;
+
+public sealed class KeycloakOptions
+{
+ public string Authority { get; set; } = string.Empty;
+ public string Audience { get; set; } = "amtocbots-api";
+}
diff --git a/src/AmtocBots.Api/Configuration/OllamaOptions.cs b/src/AmtocBots.Api/Configuration/OllamaOptions.cs
new file mode 100644
index 0000000..92028eb
--- /dev/null
+++ b/src/AmtocBots.Api/Configuration/OllamaOptions.cs
@@ -0,0 +1,6 @@
+namespace AmtocBots.Api.Configuration;
+
+public sealed class OllamaOptions
+{
+ public string BaseUrl { get; set; } = "http://localhost:11434";
+}
diff --git a/src/AmtocBots.Api/Controllers/ChatController.cs b/src/AmtocBots.Api/Controllers/ChatController.cs
new file mode 100644
index 0000000..d0a3c44
--- /dev/null
+++ b/src/AmtocBots.Api/Controllers/ChatController.cs
@@ -0,0 +1,129 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.DTOs.Chat;
+using AmtocBots.Api.Hubs;
+using AmtocBots.Api.Models;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Controllers;
+
+[ApiController]
+[Route("api/chat")]
+[Authorize]
+public sealed class ChatController(
+ AppDbContext db,
+ IHubContext hub) : ControllerBase
+{
+ [HttpGet("rooms")]
+ public async Task ListRooms(CancellationToken ct)
+ {
+ var rooms = await db.ChatRooms
+ .Select(r => new RoomSummaryDto(r.Id, r.Name, r.Description, r.IsGlobal))
+ .ToListAsync(ct);
+ return Ok(rooms);
+ }
+
+ [HttpPost("rooms")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task CreateRoom([FromBody] CreateRoomRequest req, CancellationToken ct)
+ {
+ var room = new ChatRoom
+ {
+ Name = req.Name,
+ Description = req.Description,
+ IsGlobal = req.IsGlobal,
+ CreatedBy = GetUserId(),
+ };
+ db.ChatRooms.Add(room);
+ await db.SaveChangesAsync(ct);
+ return CreatedAtAction(nameof(GetMessages), new { id = room.Id }, room.Id);
+ }
+
+ [HttpGet("rooms/{id:guid}/messages")]
+ public async Task GetMessages(
+ Guid id, [FromQuery] DateTimeOffset? before = null, [FromQuery] int limit = 50, CancellationToken ct = default)
+ {
+ var query = db.ChatMessages.Where(m => m.RoomId == id);
+ if (before.HasValue) query = query.Where(m => m.CreatedAt < before.Value);
+
+ var messages = await query
+ .OrderByDescending(m => m.CreatedAt)
+ .Take(limit)
+ .OrderBy(m => m.CreatedAt)
+ .ToListAsync(ct);
+
+ var userIds = messages.Where(m => m.SenderType == "user").Select(m => m.SenderId).Distinct().ToList();
+ var users = await db.Users.Where(u => userIds.Contains(u.Id))
+ .ToDictionaryAsync(u => u.Id, u => u.Username, ct);
+
+ var botIds = messages.Where(m => m.SenderType == "bot").Select(m => m.SenderId).Distinct().ToList();
+ var bots = await db.Instances.Where(i => botIds.Contains(i.Id))
+ .ToDictionaryAsync(i => i.Id, i => i.Name, ct);
+
+ var dtos = messages.Select(m => new MessageDto(
+ m.Id, m.RoomId, m.SenderType, m.SenderId,
+ m.SenderType == "user" ? users.GetValueOrDefault(m.SenderId, "Unknown") : bots.GetValueOrDefault(m.SenderId, "Bot"),
+ m.Content, m.Mentions, m.ReplyToId, m.CreatedAt, m.EditedAt));
+
+ return Ok(dtos);
+ }
+
+ [HttpPost("rooms/{id:guid}/members")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task AddMember(Guid id, [FromBody] AddMemberRequest req, CancellationToken ct)
+ {
+ var member = new ChatRoomMember { RoomId = id, MemberType = req.MemberType, MemberId = req.MemberId };
+ db.ChatRoomMembers.Add(member);
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpPost("rooms/{id:guid}/bot-message")]
+ [AllowAnonymous]
+ public async Task BotMessage(Guid id, [FromBody] BotMessageRequest req, CancellationToken ct)
+ {
+ // Extract instance ID from route context — identify via API key
+ var authHeader = Request.Headers.Authorization.FirstOrDefault();
+ if (authHeader is null || !authHeader.StartsWith("Bearer ")) return Unauthorized();
+ var token = authHeader["Bearer ".Length..];
+
+ var instance = await db.Instances
+ .Where(i => i.ApiBearerTokenHash != null && db.Instances.Any(x => x.Id == i.Id))
+ .ToListAsync(ct);
+
+ var matched = instance.FirstOrDefault(i =>
+ i.ApiBearerTokenHash is not null && BCrypt.Net.BCrypt.Verify(token, i.ApiBearerTokenHash));
+ if (matched is null) return Unauthorized();
+
+ var msg = new ChatMessage
+ {
+ RoomId = id,
+ SenderType = "bot",
+ SenderId = matched.Id,
+ Content = req.Content,
+ ReplyToId = req.ReplyToId,
+ };
+ db.ChatMessages.Add(msg);
+ await db.SaveChangesAsync(ct);
+
+ await hub.Clients.Group($"room:{id}").SendAsync("MessageReceived", new
+ {
+ id = msg.Id,
+ roomId = id,
+ senderType = "bot",
+ senderId = matched.Id,
+ senderName = matched.Name,
+ content = req.Content,
+ replyToId = req.ReplyToId,
+ createdAt = msg.CreatedAt,
+ }, ct);
+
+ return Ok(msg.Id);
+ }
+
+ private Guid GetUserId() => Guid.Parse(User.FindFirst("sub")!.Value);
+}
+
+public sealed record AddMemberRequest(string MemberType, Guid MemberId);
diff --git a/src/AmtocBots.Api/Controllers/InstancesController.cs b/src/AmtocBots.Api/Controllers/InstancesController.cs
new file mode 100644
index 0000000..f606c5b
--- /dev/null
+++ b/src/AmtocBots.Api/Controllers/InstancesController.cs
@@ -0,0 +1,188 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.DTOs.Instances;
+using AmtocBots.Api.Models;
+using AmtocBots.Api.Services.Docker;
+using AmtocBots.Api.Services.OpenClaw;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Controllers;
+
+[ApiController]
+[Route("api/instances")]
+[Authorize]
+public sealed class InstancesController(
+ AppDbContext db,
+ IDockerService docker,
+ OpenClawConfigBuilder configBuilder,
+ ILogger log) : ControllerBase
+{
+ [HttpGet]
+ public async Task List(CancellationToken ct)
+ {
+ var items = await db.Instances
+ .OrderBy(i => i.Name)
+ .Select(i => new InstanceSummaryDto(
+ i.Id, i.Name, i.Description, i.Status, i.CurrentModel,
+ i.ContainerName, i.HostPort, i.CpuLimit, i.MemoryLimitMb,
+ i.CreatedAt, i.UpdatedAt))
+ .ToListAsync(ct);
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task Get(Guid id, CancellationToken ct)
+ {
+ var i = await db.Instances.FindAsync([id], ct);
+ if (i is null) return NotFound();
+ return Ok(new InstanceDetailDto(i.Id, i.Name, i.Description, i.Status, i.CurrentModel,
+ i.ContainerName, i.ContainerId, i.HostPort, i.CpuLimit, i.MemoryLimitMb,
+ i.ConfigJson, i.CreatedAt, i.UpdatedAt));
+ }
+
+ [HttpPost]
+ [Authorize(Roles = "admin,operator")]
+ public async Task Create([FromBody] CreateInstanceRequest req, CancellationToken ct)
+ {
+ var userId = GetUserId();
+
+ // Allocate next port (serialized via DB transaction)
+ await using var tx = await db.Database.BeginTransactionAsync(ct);
+ var maxPort = await db.Instances.MaxAsync(i => (int?)i.HostPort, ct) ?? 18788;
+ var port = maxPort + 1;
+
+ // Generate a plain-text bearer token (shown once) + hash for storage
+ var token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
+ var tokenHash = BCrypt.Net.BCrypt.HashPassword(token);
+
+ var containerName = $"openclaw-{req.Name.ToLower().Replace(" ", "-")}";
+ var instance = new BotInstance
+ {
+ Name = req.Name,
+ Description = req.Description,
+ CurrentModel = req.Model,
+ ContainerName = containerName,
+ HostPort = port,
+ CpuLimit = req.CpuLimit,
+ MemoryLimitMb = req.MemoryLimitMb,
+ ApiBearerTokenHash = tokenHash,
+ CreatedBy = userId,
+ };
+
+ db.Instances.Add(instance);
+ await db.SaveChangesAsync(ct);
+ await tx.CommitAsync(ct);
+
+ log.LogInformation("Created instance {Name} (port {Port})", req.Name, port);
+
+ return CreatedAtAction(nameof(Get), new { id = instance.Id },
+ new InstanceTokenResponse(instance.Id, token));
+ }
+
+ [HttpPut("{id:guid}")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task Update(Guid id, [FromBody] UpdateInstanceRequest req, CancellationToken ct)
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return NotFound();
+
+ if (req.Name is not null) instance.Name = req.Name;
+ if (req.Description is not null) instance.Description = req.Description;
+ if (req.Model is not null) instance.CurrentModel = req.Model;
+ if (req.CpuLimit.HasValue) instance.CpuLimit = req.CpuLimit;
+ if (req.MemoryLimitMb.HasValue) instance.MemoryLimitMb = req.MemoryLimitMb;
+ instance.UpdatedAt = DateTimeOffset.UtcNow;
+
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpDelete("{id:guid}")]
+ [Authorize(Roles = "admin")]
+ public async Task Delete(Guid id, CancellationToken ct)
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return NotFound();
+
+ if (instance.ContainerId is not null)
+ {
+ try { await docker.RemoveContainerAsync(instance.ContainerId, force: true, ct); }
+ catch (Exception ex) { log.LogWarning(ex, "Failed to remove container for instance {Id}", id); }
+ }
+
+ db.Instances.Remove(instance);
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpPost("{id:guid}/start")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task Start(Guid id, CancellationToken ct)
+ {
+ var instance = await db.Instances.Include(i => i.ChannelConfigs).FirstOrDefaultAsync(i => i.Id == id, ct);
+ if (instance is null) return NotFound();
+
+ // Write config to volume first
+ var config = configBuilder.Build(instance, instance.ChannelConfigs, "placeholder");
+ await docker.WriteConfigVolumeAsync(id, config, ct);
+
+ var containerId = await docker.CreateAndStartContainerAsync(instance, ct);
+ instance.ContainerId = containerId;
+ instance.Status = "running";
+ instance.UpdatedAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync(ct);
+
+ return Ok(new { containerId });
+ }
+
+ [HttpPost("{id:guid}/stop")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task Stop(Guid id, CancellationToken ct)
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return NotFound();
+ if (instance.ContainerId is null) return BadRequest("Instance not running");
+
+ await docker.StopContainerAsync(instance.ContainerId, ct);
+ instance.Status = "stopped";
+ instance.UpdatedAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpPost("{id:guid}/restart")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task Restart(Guid id, CancellationToken ct)
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return NotFound();
+ if (instance.ContainerId is null) return BadRequest("Instance not running");
+
+ await docker.RestartContainerAsync(instance.ContainerId, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{id:guid}/logs")]
+ public async Task Logs(Guid id, [FromQuery] int tail = 200, CancellationToken ct = default)
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return NotFound();
+ if (instance.ContainerId is null) return BadRequest("Instance not running");
+
+ var logs = await docker.GetLogsAsync(instance.ContainerId, tail, ct);
+ return Content(logs, "text/plain");
+ }
+
+ [HttpGet("{id:guid}/config")]
+ public async Task GetConfig(Guid id, CancellationToken ct)
+ {
+ var instance = await db.Instances.Include(i => i.ChannelConfigs).FirstOrDefaultAsync(i => i.Id == id, ct);
+ if (instance is null) return NotFound();
+ var config = configBuilder.Build(instance, instance.ChannelConfigs, "[redacted]");
+ return Content(config, "text/plain");
+ }
+
+ private Guid GetUserId() =>
+ Guid.Parse(User.FindFirst("sub")!.Value);
+}
diff --git a/src/AmtocBots.Api/Controllers/KanbanController.cs b/src/AmtocBots.Api/Controllers/KanbanController.cs
new file mode 100644
index 0000000..114c62e
--- /dev/null
+++ b/src/AmtocBots.Api/Controllers/KanbanController.cs
@@ -0,0 +1,151 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.DTOs.Kanban;
+using AmtocBots.Api.Hubs;
+using AmtocBots.Api.Models;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Controllers;
+
+[ApiController]
+[Route("api/kanban")]
+[Authorize]
+public sealed class KanbanController(
+ AppDbContext db,
+ IHubContext hub) : ControllerBase
+{
+ [HttpGet("boards")]
+ public async Task ListBoards(CancellationToken ct)
+ {
+ var boards = await db.KanbanBoards
+ .Select(b => new BoardSummaryDto(b.Id, b.Name, b.Description, b.CreatedAt))
+ .ToListAsync(ct);
+ return Ok(boards);
+ }
+
+ [HttpPost("boards")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task CreateBoard([FromBody] CreateBoardRequest req, CancellationToken ct)
+ {
+ var board = new KanbanBoard { Name = req.Name, Description = req.Description, CreatedBy = GetUserId() };
+ // Default columns
+ board.Columns.Add(new KanbanColumn { Name = "Backlog", Position = 0 });
+ board.Columns.Add(new KanbanColumn { Name = "In Progress", Position = 1, Color = "#3b82f6" });
+ board.Columns.Add(new KanbanColumn { Name = "Review", Position = 2, Color = "#f59e0b" });
+ board.Columns.Add(new KanbanColumn { Name = "Done", Position = 3, Color = "#10b981" });
+ db.KanbanBoards.Add(board);
+ await db.SaveChangesAsync(ct);
+ return CreatedAtAction(nameof(GetBoard), new { id = board.Id }, board.Id);
+ }
+
+ [HttpGet("boards/{id:guid}")]
+ public async Task GetBoard(Guid id, CancellationToken ct)
+ {
+ var board = await db.KanbanBoards
+ .Include(b => b.Columns.OrderBy(c => c.Position))
+ .ThenInclude(c => c.Cards.OrderBy(k => k.Position))
+ .ThenInclude(k => k.AssignedInstance)
+ .FirstOrDefaultAsync(b => b.Id == id, ct);
+ if (board is null) return NotFound();
+
+ return Ok(new BoardDetailDto(board.Id, board.Name, board.Description,
+ board.Columns.Select(c => new ColumnDto(c.Id, c.Name, c.Position, c.Color, c.WipLimit,
+ c.Cards.Select(k => MapCard(k)).ToList())).ToList()));
+ }
+
+ [HttpPost("boards/{id:guid}/cards")]
+ public async Task CreateCard(Guid id, [FromBody] CreateCardRequest req, CancellationToken ct)
+ {
+ var firstCol = await db.KanbanColumns
+ .Where(c => c.BoardId == id)
+ .OrderBy(c => c.Position)
+ .FirstOrDefaultAsync(ct);
+ if (firstCol is null) return BadRequest("Board has no columns");
+
+ var maxPos = await db.KanbanCards.Where(k => k.ColumnId == firstCol.Id).MaxAsync(k => (int?)k.Position, ct) ?? -1;
+ var card = new KanbanCard
+ {
+ ColumnId = firstCol.Id,
+ BoardId = id,
+ Title = req.Title,
+ Description = req.Description,
+ Priority = req.Priority,
+ Labels = req.Labels ?? [],
+ DueDate = req.DueDate,
+ Position = maxPos + 1,
+ AssignedInstanceId = req.AssignedInstanceId,
+ AssignedUserId = req.AssignedUserId,
+ CreatedByType = "human",
+ CreatedById = GetUserId(),
+ };
+ db.KanbanCards.Add(card);
+ await db.SaveChangesAsync(ct);
+
+ await hub.Clients.Group($"board:{id}").SendAsync("CardCreated", MapCard(card), ct);
+ return CreatedAtAction(nameof(GetBoard), new { id }, card.Id);
+ }
+
+ [HttpPatch("cards/{cardId:guid}/move")]
+ public async Task MoveCard(Guid cardId, [FromBody] MoveCardRequest req, CancellationToken ct)
+ {
+ var card = await db.KanbanCards.FindAsync([cardId], ct);
+ if (card is null) return NotFound();
+
+ card.ColumnId = req.TargetColumnId;
+ card.Position = req.Position;
+ card.UpdatedAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync(ct);
+
+ await hub.Clients.Group($"board:{card.BoardId}").SendAsync("CardMoved",
+ new { cardId, targetColumnId = req.TargetColumnId, position = req.Position }, ct);
+ return NoContent();
+ }
+
+ [HttpPost("bot-webhook")]
+ [AllowAnonymous] // auth handled manually via API key
+ public async Task BotWebhook([FromBody] BotWebhookCardRequest req, CancellationToken ct)
+ {
+ var instance = await db.Instances
+ .Where(i => i.Id == req.InstanceId)
+ .Select(i => new { i.ApiBearerTokenHash })
+ .FirstOrDefaultAsync(ct);
+
+ if (instance?.ApiBearerTokenHash is null || !BCrypt.Net.BCrypt.Verify(req.ApiKey, instance.ApiBearerTokenHash))
+ return Unauthorized();
+
+ var colId = req.ColumnId ?? (await db.KanbanColumns
+ .Where(c => c.BoardId == req.BoardId)
+ .OrderBy(c => c.Position)
+ .Select(c => c.Id)
+ .FirstOrDefaultAsync(ct));
+
+ if (colId == default) return BadRequest("Board not found or has no columns");
+
+ var maxPos = await db.KanbanCards.Where(k => k.ColumnId == colId).MaxAsync(k => (int?)k.Position, ct) ?? -1;
+ var card = new KanbanCard
+ {
+ ColumnId = colId,
+ BoardId = req.BoardId,
+ Title = req.Title,
+ Description = req.Description,
+ Priority = req.Priority,
+ Position = maxPos + 1,
+ AssignedInstanceId = req.InstanceId,
+ CreatedByType = "bot",
+ CreatedById = req.InstanceId,
+ };
+ db.KanbanCards.Add(card);
+ await db.SaveChangesAsync(ct);
+
+ await hub.Clients.Group($"board:{req.BoardId}").SendAsync("CardCreated", MapCard(card), ct);
+ return Ok(card.Id);
+ }
+
+ private static CardDto MapCard(KanbanCard k) => new(
+ k.Id, k.ColumnId, k.Title, k.Description, k.Priority, k.Labels, k.DueDate,
+ k.Position, k.AssignedInstanceId, k.AssignedUserId, k.CreatedByType, k.CreatedById, k.CreatedAt);
+
+ private Guid GetUserId() => Guid.Parse(User.FindFirst("sub")!.Value);
+}
diff --git a/src/AmtocBots.Api/Controllers/ModelsController.cs b/src/AmtocBots.Api/Controllers/ModelsController.cs
new file mode 100644
index 0000000..a2326af
--- /dev/null
+++ b/src/AmtocBots.Api/Controllers/ModelsController.cs
@@ -0,0 +1,141 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.DTOs.Models;
+using AmtocBots.Api.Models;
+using AmtocBots.Api.Services.Models;
+using AmtocBots.Api.Services.Ollama;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Controllers;
+
+[ApiController]
+[Route("api")]
+[Authorize]
+public sealed class ModelsController(
+ AppDbContext db,
+ IModelSwitchingService switcher,
+ ITokenTracker tracker,
+ IOllamaService ollama) : ControllerBase
+{
+ private static readonly string[] BuiltInProviders =
+ [
+ "anthropic/claude-opus-4-6",
+ "anthropic/claude-sonnet-4-6",
+ "anthropic/claude-haiku-4-5-20251001",
+ "openai/gpt-4o",
+ "openai/gpt-4o-mini",
+ "google/gemini-2.0-flash",
+ "openrouter/auto",
+ ];
+
+ [HttpGet("models/available")]
+ public async Task Available(CancellationToken ct)
+ {
+ var ollamaModels = await ollama.ListModelsAsync(ct);
+ var all = BuiltInProviders
+ .Select(m => new { id = m, provider = m.Split('/')[0], local = false })
+ .Concat(ollamaModels.Select(m => new { id = $"ollama/{m.Name}", provider = "ollama", local = true }));
+ return Ok(all);
+ }
+
+ [HttpGet("instances/{id:guid}/model")]
+ public async Task GetModel(Guid id, CancellationToken ct)
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return NotFound();
+ return Ok(new { model = instance.CurrentModel });
+ }
+
+ [HttpPut("instances/{id:guid}/model")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task SwitchModel(Guid id, [FromBody] SwitchModelRequest req, CancellationToken ct)
+ {
+ await switcher.SwitchModelAsync(id, req.Model, ct);
+ return NoContent();
+ }
+
+ [HttpGet("instances/{id:guid}/token-usage")]
+ public async Task InstanceUsage(Guid id, [FromQuery] int days = 7, CancellationToken ct = default)
+ {
+ var from = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days));
+ var records = await db.TokenUsage
+ .Where(r => r.InstanceId == id && r.UsageDate >= from)
+ .OrderBy(r => r.UsageDate)
+ .Select(r => new TokenUsageSummaryDto(r.InstanceId, r.Model, r.UsageDate,
+ r.PromptTokens, r.CompletionTokens, r.TotalTokens, r.EstimatedCostUsd))
+ .ToListAsync(ct);
+ return Ok(records);
+ }
+
+ [HttpGet("token-usage/summary")]
+ public async Task Summary([FromQuery] int days = 7, CancellationToken ct = default)
+ {
+ var from = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days));
+ var records = await db.TokenUsage
+ .Where(r => r.UsageDate >= from)
+ .OrderBy(r => r.UsageDate)
+ .Select(r => new TokenUsageSummaryDto(r.InstanceId, r.Model, r.UsageDate,
+ r.PromptTokens, r.CompletionTokens, r.TotalTokens, r.EstimatedCostUsd))
+ .ToListAsync(ct);
+ return Ok(records);
+ }
+
+ [HttpGet("instances/{id:guid}/switch-rules")]
+ public async Task ListRules(Guid id, CancellationToken ct)
+ {
+ var rules = await db.SwitchRules
+ .Where(r => r.InstanceId == id)
+ .OrderByDescending(r => r.Priority)
+ .Select(r => new SwitchRuleDto(r.Id, r.RuleType, r.TriggerModel, r.ThresholdPct,
+ r.CronExpression, r.TargetModel, r.IsActive, r.Priority))
+ .ToListAsync(ct);
+ return Ok(rules);
+ }
+
+ [HttpPost("instances/{id:guid}/switch-rules")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task CreateRule(Guid id, [FromBody] CreateSwitchRuleRequest req, CancellationToken ct)
+ {
+ var rule = new ModelSwitchRule
+ {
+ InstanceId = id,
+ RuleType = req.RuleType,
+ TriggerModel = req.TriggerModel,
+ ThresholdPct = req.ThresholdPct,
+ CronExpression = req.CronExpression,
+ TargetModel = req.TargetModel,
+ Priority = req.Priority,
+ };
+ db.SwitchRules.Add(rule);
+ await db.SaveChangesAsync(ct);
+ return CreatedAtAction(nameof(ListRules), new { id }, rule.Id);
+ }
+
+ [HttpPut("instances/{id:guid}/switch-rules/{ruleId:guid}")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task UpdateRule(Guid id, Guid ruleId, [FromBody] CreateSwitchRuleRequest req, CancellationToken ct)
+ {
+ var rule = await db.SwitchRules.FirstOrDefaultAsync(r => r.Id == ruleId && r.InstanceId == id, ct);
+ if (rule is null) return NotFound();
+ rule.RuleType = req.RuleType;
+ rule.TriggerModel = req.TriggerModel;
+ rule.ThresholdPct = req.ThresholdPct;
+ rule.CronExpression = req.CronExpression;
+ rule.TargetModel = req.TargetModel;
+ rule.Priority = req.Priority;
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpDelete("instances/{id:guid}/switch-rules/{ruleId:guid}")]
+ [Authorize(Roles = "admin,operator")]
+ public async Task DeleteRule(Guid id, Guid ruleId, CancellationToken ct)
+ {
+ var rule = await db.SwitchRules.FirstOrDefaultAsync(r => r.Id == ruleId && r.InstanceId == id, ct);
+ if (rule is null) return NotFound();
+ db.SwitchRules.Remove(rule);
+ await db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+}
diff --git a/src/AmtocBots.Api/Controllers/WebhookController.cs b/src/AmtocBots.Api/Controllers/WebhookController.cs
new file mode 100644
index 0000000..7b13255
--- /dev/null
+++ b/src/AmtocBots.Api/Controllers/WebhookController.cs
@@ -0,0 +1,69 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Services.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using System.Text.Json.Serialization;
+
+namespace AmtocBots.Api.Controllers;
+
+///
+/// Receives callbacks from OpenClaw instances (or the token-tracking sidecar proxy).
+/// These endpoints use instance API key auth, not JWT.
+///
+[ApiController]
+[Route("api/webhooks/openclaw")]
+public sealed class WebhookController(
+ AppDbContext db,
+ ITokenTracker tracker,
+ ILogger log) : ControllerBase
+{
+ [HttpPost("{instanceId:guid}/token-usage")]
+ public async Task TokenUsage(Guid instanceId, [FromBody] TokenUsagePayload payload, CancellationToken ct)
+ {
+ if (!await ValidateApiKey(instanceId, ct)) return Unauthorized();
+
+ await tracker.RecordAsync(new TokenUsageSample(
+ instanceId,
+ payload.Model,
+ payload.PromptTokens,
+ payload.CompletionTokens,
+ payload.CostUsd), ct);
+
+ return Ok();
+ }
+
+ [HttpPost("{instanceId:guid}/event")]
+ public async Task Event(Guid instanceId, [FromBody] EventPayload payload, CancellationToken ct)
+ {
+ if (!await ValidateApiKey(instanceId, ct)) return Unauthorized();
+
+ log.LogInformation("OpenClaw event from instance {Id}: {Type}", instanceId, payload.Type);
+ // Future: handle specific events (session reset, error, etc.)
+ return Ok();
+ }
+
+ private async Task ValidateApiKey(Guid instanceId, CancellationToken ct)
+ {
+ var authHeader = Request.Headers.Authorization.FirstOrDefault();
+ if (authHeader is null || !authHeader.StartsWith("Bearer ")) return false;
+ var token = authHeader["Bearer ".Length..];
+
+ var instance = await db.Instances
+ .Where(i => i.Id == instanceId)
+ .Select(i => new { i.ApiBearerTokenHash })
+ .FirstOrDefaultAsync(ct);
+
+ return instance?.ApiBearerTokenHash is not null
+ && BCrypt.Net.BCrypt.Verify(token, instance.ApiBearerTokenHash);
+ }
+}
+
+public sealed record TokenUsagePayload(
+ [property: JsonPropertyName("model")] string Model,
+ [property: JsonPropertyName("prompt_tokens")] long PromptTokens,
+ [property: JsonPropertyName("completion_tokens")] long CompletionTokens,
+ [property: JsonPropertyName("cost_usd")] decimal? CostUsd);
+
+public sealed record EventPayload(
+ [property: JsonPropertyName("type")] string Type,
+ [property: JsonPropertyName("data")] object? Data);
diff --git a/src/AmtocBots.Api/DTOs/Chat/ChatDtos.cs b/src/AmtocBots.Api/DTOs/Chat/ChatDtos.cs
new file mode 100644
index 0000000..5eab3cf
--- /dev/null
+++ b/src/AmtocBots.Api/DTOs/Chat/ChatDtos.cs
@@ -0,0 +1,24 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace AmtocBots.Api.DTOs.Chat;
+
+public sealed record RoomSummaryDto(Guid Id, string Name, string? Description, bool IsGlobal);
+
+public sealed record MessageDto(
+ Guid Id,
+ Guid RoomId,
+ string SenderType,
+ Guid SenderId,
+ string SenderName,
+ string Content,
+ Guid[] Mentions,
+ Guid? ReplyToId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? EditedAt);
+
+public sealed record CreateRoomRequest([Required, MaxLength(100)] string Name, string? Description, bool IsGlobal = false);
+
+public sealed record BotMessageRequest(
+ [Required] string ApiKey,
+ [Required] string Content,
+ Guid? ReplyToId = null);
diff --git a/src/AmtocBots.Api/DTOs/Instances/InstanceDtos.cs b/src/AmtocBots.Api/DTOs/Instances/InstanceDtos.cs
new file mode 100644
index 0000000..3ceda5f
--- /dev/null
+++ b/src/AmtocBots.Api/DTOs/Instances/InstanceDtos.cs
@@ -0,0 +1,49 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace AmtocBots.Api.DTOs.Instances;
+
+public sealed record InstanceSummaryDto(
+ Guid Id,
+ string Name,
+ string? Description,
+ string Status,
+ string CurrentModel,
+ string ContainerName,
+ int HostPort,
+ decimal? CpuLimit,
+ int? MemoryLimitMb,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public sealed record InstanceDetailDto(
+ Guid Id,
+ string Name,
+ string? Description,
+ string Status,
+ string CurrentModel,
+ string ContainerName,
+ string? ContainerId,
+ int HostPort,
+ decimal? CpuLimit,
+ int? MemoryLimitMb,
+ string? ConfigJson,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt);
+
+public sealed record CreateInstanceRequest(
+ [Required, MaxLength(100)] string Name,
+ string? Description,
+ string Model = "anthropic/claude-sonnet-4-6",
+ decimal? CpuLimit = null,
+ int? MemoryLimitMb = null);
+
+public sealed record UpdateInstanceRequest(
+ string? Name,
+ string? Description,
+ string? Model,
+ decimal? CpuLimit,
+ int? MemoryLimitMb);
+
+public sealed record InstanceTokenResponse(
+ Guid InstanceId,
+ string Token); // shown once on creation
diff --git a/src/AmtocBots.Api/DTOs/Kanban/KanbanDtos.cs b/src/AmtocBots.Api/DTOs/Kanban/KanbanDtos.cs
new file mode 100644
index 0000000..4b58b1b
--- /dev/null
+++ b/src/AmtocBots.Api/DTOs/Kanban/KanbanDtos.cs
@@ -0,0 +1,48 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace AmtocBots.Api.DTOs.Kanban;
+
+public sealed record BoardSummaryDto(Guid Id, string Name, string? Description, DateTimeOffset CreatedAt);
+
+public sealed record BoardDetailDto(Guid Id, string Name, string? Description, List Columns);
+
+public sealed record ColumnDto(Guid Id, string Name, int Position, string? Color, int? WipLimit, List Cards);
+
+public sealed record CardDto(
+ Guid Id,
+ Guid ColumnId,
+ string Title,
+ string? Description,
+ string? Priority,
+ string[] Labels,
+ DateTimeOffset? DueDate,
+ int Position,
+ Guid? AssignedInstanceId,
+ Guid? AssignedUserId,
+ string CreatedByType,
+ Guid CreatedById,
+ DateTimeOffset CreatedAt);
+
+public sealed record CreateBoardRequest([Required, MaxLength(100)] string Name, string? Description);
+
+public sealed record CreateColumnRequest([Required] string Name, int Position, string? Color, int? WipLimit);
+
+public sealed record CreateCardRequest(
+ [Required, MaxLength(255)] string Title,
+ string? Description,
+ string? Priority,
+ string[]? Labels,
+ DateTimeOffset? DueDate,
+ Guid? AssignedInstanceId,
+ Guid? AssignedUserId);
+
+public sealed record MoveCardRequest(Guid TargetColumnId, int Position);
+
+public sealed record BotWebhookCardRequest(
+ [Required] string ApiKey,
+ [Required] Guid BoardId,
+ Guid? ColumnId, // defaults to first column if omitted
+ [Required, MaxLength(255)] string Title,
+ string? Description,
+ string? Priority,
+ Guid InstanceId);
diff --git a/src/AmtocBots.Api/DTOs/Models/ModelDtos.cs b/src/AmtocBots.Api/DTOs/Models/ModelDtos.cs
new file mode 100644
index 0000000..aa27a56
--- /dev/null
+++ b/src/AmtocBots.Api/DTOs/Models/ModelDtos.cs
@@ -0,0 +1,32 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace AmtocBots.Api.DTOs.Models;
+
+public sealed record SwitchRuleDto(
+ Guid Id,
+ string RuleType,
+ string? TriggerModel,
+ int? ThresholdPct,
+ string? CronExpression,
+ string TargetModel,
+ bool IsActive,
+ int Priority);
+
+public sealed record CreateSwitchRuleRequest(
+ [Required] string RuleType,
+ string? TriggerModel,
+ int? ThresholdPct,
+ string? CronExpression,
+ [Required] string TargetModel,
+ int Priority = 0);
+
+public sealed record TokenUsageSummaryDto(
+ Guid InstanceId,
+ string Model,
+ DateOnly Date,
+ long PromptTokens,
+ long CompletionTokens,
+ long TotalTokens,
+ decimal? EstimatedCostUsd);
+
+public sealed record SwitchModelRequest([Required] string Model);
diff --git a/src/AmtocBots.Api/Dockerfile b/src/AmtocBots.Api/Dockerfile
new file mode 100644
index 0000000..dd48dd4
--- /dev/null
+++ b/src/AmtocBots.Api/Dockerfile
@@ -0,0 +1,12 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY *.csproj .
+RUN dotnet restore
+COPY . .
+RUN dotnet publish -c Release -o /app/publish
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
+WORKDIR /app
+COPY --from=build /app/publish .
+EXPOSE 8080
+ENTRYPOINT ["dotnet", "AmtocBots.Api.dll"]
diff --git a/src/AmtocBots.Api/Endpoints/ChannelEndpoints.cs b/src/AmtocBots.Api/Endpoints/ChannelEndpoints.cs
new file mode 100644
index 0000000..609a130
--- /dev/null
+++ b/src/AmtocBots.Api/Endpoints/ChannelEndpoints.cs
@@ -0,0 +1,84 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Models;
+using AmtocBots.Api.Services.Docker;
+using AmtocBots.Api.Services.OpenClaw;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Endpoints;
+
+public static class ChannelEndpoints
+{
+ public static RouteGroupBuilder MapChannelEndpoints(this RouteGroupBuilder group)
+ {
+ group.MapGet("/instances/{id:guid}/channels", async (Guid id, AppDbContext db, CancellationToken ct) =>
+ {
+ var configs = await db.ChannelConfigs
+ .Where(c => c.InstanceId == id)
+ .Select(c => new { c.Id, c.ChannelType, c.IsEnabled, c.UpdatedAt })
+ .ToListAsync(ct);
+ return Results.Ok(configs);
+ });
+
+ group.MapPut("/instances/{id:guid}/channels/{type}", async (
+ Guid id, string type,
+ [FromBody] UpsertChannelRequest req,
+ AppDbContext db,
+ IDockerService docker,
+ OpenClawConfigBuilder configBuilder,
+ CancellationToken ct) =>
+ {
+ var existing = await db.ChannelConfigs
+ .FirstOrDefaultAsync(c => c.InstanceId == id && c.ChannelType == type, ct);
+
+ if (existing is null)
+ {
+ db.ChannelConfigs.Add(new ChannelConfig
+ {
+ InstanceId = id, ChannelType = type,
+ IsEnabled = req.IsEnabled, ConfigJson = req.ConfigJson,
+ });
+ }
+ else
+ {
+ existing.IsEnabled = req.IsEnabled;
+ existing.ConfigJson = req.ConfigJson;
+ existing.UpdatedAt = DateTimeOffset.UtcNow;
+ }
+
+ await db.SaveChangesAsync(ct);
+
+ // Rebuild and write config, then restart if running
+ var instance = await db.Instances.Include(i => i.ChannelConfigs).FirstOrDefaultAsync(i => i.Id == id, ct);
+ if (instance is not null)
+ {
+ var config = configBuilder.Build(instance, instance.ChannelConfigs, "placeholder");
+ await docker.WriteConfigVolumeAsync(id, config, ct);
+ if (instance.ContainerId is not null && instance.Status == "running")
+ await docker.RestartContainerAsync(instance.ContainerId, ct);
+ }
+
+ return Results.NoContent();
+ });
+
+ group.MapGet("/instances/{id:guid}/channels/whatsapp/qr", async (
+ Guid id, AppDbContext db,
+ IOpenClawClient openClaw, CancellationToken ct) =>
+ {
+ var instance = await db.Instances.FindAsync([id], ct);
+ if (instance is null) return Results.NotFound();
+ if (instance.ContainerId is null) return Results.BadRequest("Instance not running");
+
+ // Proxy QR from container on openclaw network
+ var baseUrl = $"http://{instance.ContainerName}:18789";
+ var qr = await openClaw.GetWhatsAppQrAsync(baseUrl, string.Empty, ct);
+ if (qr is null) return Results.NotFound("QR not available yet");
+
+ return Results.File(qr.ImageBytes, qr.ContentType);
+ });
+
+ return group;
+ }
+}
+
+public sealed record UpsertChannelRequest(bool IsEnabled, string ConfigJson);
diff --git a/src/AmtocBots.Api/Endpoints/HealthEndpoints.cs b/src/AmtocBots.Api/Endpoints/HealthEndpoints.cs
new file mode 100644
index 0000000..5bb15ef
--- /dev/null
+++ b/src/AmtocBots.Api/Endpoints/HealthEndpoints.cs
@@ -0,0 +1,10 @@
+namespace AmtocBots.Api.Endpoints;
+
+public static class HealthEndpoints
+{
+ public static RouteGroupBuilder MapHealthEndpoints(this RouteGroupBuilder group)
+ {
+ group.MapGet("/", () => Results.Ok(new { status = "healthy", utc = DateTimeOffset.UtcNow }));
+ return group;
+ }
+}
diff --git a/src/AmtocBots.Api/Endpoints/OllamaEndpoints.cs b/src/AmtocBots.Api/Endpoints/OllamaEndpoints.cs
new file mode 100644
index 0000000..eebfdae
--- /dev/null
+++ b/src/AmtocBots.Api/Endpoints/OllamaEndpoints.cs
@@ -0,0 +1,31 @@
+using AmtocBots.Api.Services.Ollama;
+
+namespace AmtocBots.Api.Endpoints;
+
+public static class OllamaEndpoints
+{
+ public static RouteGroupBuilder MapOllamaEndpoints(this RouteGroupBuilder group)
+ {
+ group.MapGet("/models", async (IOllamaService ollama, CancellationToken ct) =>
+ {
+ var models = await ollama.ListModelsAsync(ct);
+ return Results.Ok(models);
+ });
+
+ group.MapPost("/pull", async (PullModelRequest req, IOllamaService ollama, CancellationToken ct) =>
+ {
+ await ollama.PullModelAsync(req.Model, ct);
+ return Results.Accepted();
+ });
+
+ group.MapGet("/status", async (IOllamaService ollama, CancellationToken ct) =>
+ {
+ var healthy = await ollama.IsHealthyAsync(ct);
+ return Results.Ok(new { healthy });
+ });
+
+ return group;
+ }
+}
+
+public sealed record PullModelRequest(string Model);
diff --git a/src/AmtocBots.Api/Hubs/ChatHub.cs b/src/AmtocBots.Api/Hubs/ChatHub.cs
new file mode 100644
index 0000000..208c080
--- /dev/null
+++ b/src/AmtocBots.Api/Hubs/ChatHub.cs
@@ -0,0 +1,63 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Models;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Hubs;
+
+[Authorize]
+public sealed class ChatHub(AppDbContext db) : Hub
+{
+ public async Task JoinRoom(string roomId)
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, $"room:{roomId}");
+ await Clients.Group($"room:{roomId}")
+ .SendAsync("UserJoined", GetUserId(), GetUsername());
+ }
+
+ public async Task LeaveRoom(string roomId)
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"room:{roomId}");
+ await Clients.Group($"room:{roomId}")
+ .SendAsync("UserLeft", GetUserId(), GetUsername());
+ }
+
+ public async Task SendMessage(string roomId, string content, string? replyToId = null)
+ {
+ var userId = GetUserId();
+ var msg = new ChatMessage
+ {
+ RoomId = Guid.Parse(roomId),
+ SenderType = "user",
+ SenderId = userId,
+ Content = content,
+ ReplyToId = replyToId is null ? null : Guid.Parse(replyToId),
+ };
+
+ db.ChatMessages.Add(msg);
+ await db.SaveChangesAsync();
+
+ await Clients.Group($"room:{roomId}").SendAsync("MessageReceived", new
+ {
+ id = msg.Id,
+ roomId,
+ senderType = "user",
+ senderId = userId,
+ senderName = GetUsername(),
+ content,
+ replyToId,
+ createdAt = msg.CreatedAt,
+ });
+ }
+
+ public Task SendTyping(string roomId) =>
+ Clients.OthersInGroup($"room:{roomId}")
+ .SendAsync("UserTyping", GetUserId(), GetUsername());
+
+ private Guid GetUserId() =>
+ Guid.Parse(Context.User!.FindFirst("sub")!.Value);
+
+ private string GetUsername() =>
+ Context.User!.FindFirst("preferred_username")?.Value ?? "Unknown";
+}
diff --git a/src/AmtocBots.Api/Hubs/InstanceHub.cs b/src/AmtocBots.Api/Hubs/InstanceHub.cs
new file mode 100644
index 0000000..201ca83
--- /dev/null
+++ b/src/AmtocBots.Api/Hubs/InstanceHub.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+
+namespace AmtocBots.Api.Hubs;
+
+[Authorize]
+public sealed class InstanceHub : Hub
+{
+ public Task SubscribeToInstance(string instanceId) =>
+ Groups.AddToGroupAsync(Context.ConnectionId, $"instance:{instanceId}");
+
+ public Task UnsubscribeFromInstance(string instanceId) =>
+ Groups.RemoveFromGroupAsync(Context.ConnectionId, $"instance:{instanceId}");
+}
diff --git a/src/AmtocBots.Api/Hubs/KanbanHub.cs b/src/AmtocBots.Api/Hubs/KanbanHub.cs
new file mode 100644
index 0000000..6f0414f
--- /dev/null
+++ b/src/AmtocBots.Api/Hubs/KanbanHub.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+
+namespace AmtocBots.Api.Hubs;
+
+[Authorize]
+public sealed class KanbanHub : Hub
+{
+ public Task JoinBoard(string boardId) =>
+ Groups.AddToGroupAsync(Context.ConnectionId, $"board:{boardId}");
+
+ public Task LeaveBoard(string boardId) =>
+ Groups.RemoveFromGroupAsync(Context.ConnectionId, $"board:{boardId}");
+}
diff --git a/src/AmtocBots.Api/Models/AppUser.cs b/src/AmtocBots.Api/Models/AppUser.cs
new file mode 100644
index 0000000..90f27e8
--- /dev/null
+++ b/src/AmtocBots.Api/Models/AppUser.cs
@@ -0,0 +1,14 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class AppUser
+{
+ /// Matches Keycloak 'sub' claim.
+ public Guid Id { get; set; }
+ public string Username { get; set; } = string.Empty;
+ public string Email { get; set; } = string.Empty;
+ public string Role { get; set; } = "viewer"; // admin | operator | viewer
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset? LastSeen { get; set; }
+
+ public ICollection CreatedInstances { get; set; } = [];
+}
diff --git a/src/AmtocBots.Api/Models/BotInstance.cs b/src/AmtocBots.Api/Models/BotInstance.cs
new file mode 100644
index 0000000..ca22896
--- /dev/null
+++ b/src/AmtocBots.Api/Models/BotInstance.cs
@@ -0,0 +1,38 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class BotInstance
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+
+ // Docker
+ public string? ContainerId { get; set; }
+ public string ContainerName { get; set; } = string.Empty;
+ public int HostPort { get; set; }
+ public string Status { get; set; } = "stopped"; // stopped | starting | running | error
+
+ // Model
+ public string CurrentModel { get; set; } = "anthropic/claude-sonnet-4-6";
+
+ // Resource limits
+ public decimal? CpuLimit { get; set; }
+ public int? MemoryLimitMb { get; set; }
+
+ // Stored as JSON5 text; built from ChannelConfigs + model by OpenClawConfigBuilder
+ public string? ConfigJson { get; set; }
+
+ // Bcrypt hash of the bearer token issued to this instance for /hooks/* calls
+ public string? ApiBearerTokenHash { get; set; }
+
+ public Guid CreatedBy { get; set; }
+ public AppUser? Creator { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public ICollection ChannelConfigs { get; set; } = [];
+ public ICollection SwitchRules { get; set; } = [];
+ public ICollection TokenUsage { get; set; } = [];
+ public ICollection Learnings { get; set; } = [];
+}
diff --git a/src/AmtocBots.Api/Models/BotLearning.cs b/src/AmtocBots.Api/Models/BotLearning.cs
new file mode 100644
index 0000000..e2afa3d
--- /dev/null
+++ b/src/AmtocBots.Api/Models/BotLearning.cs
@@ -0,0 +1,18 @@
+using Pgvector;
+
+namespace AmtocBots.Api.Models;
+
+public sealed class BotLearning
+{
+ public Guid Id { get; set; }
+ public Guid SourceInstanceId { get; set; }
+ public BotInstance? SourceInstance { get; set; }
+
+ public string Content { get; set; } = string.Empty;
+
+ /// 1536-dimension vector embedding (OpenAI/Ollama compatible).
+ public Vector? Embedding { get; set; }
+
+ public string[] Tags { get; set; } = [];
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/AmtocBots.Api/Models/ChannelConfig.cs b/src/AmtocBots.Api/Models/ChannelConfig.cs
new file mode 100644
index 0000000..6cbd637
--- /dev/null
+++ b/src/AmtocBots.Api/Models/ChannelConfig.cs
@@ -0,0 +1,21 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class ChannelConfig
+{
+ public Guid Id { get; set; }
+ public Guid InstanceId { get; set; }
+ public BotInstance? Instance { get; set; }
+
+ /// telegram | whatsapp | discord | slack
+ public string ChannelType { get; set; } = string.Empty;
+ public bool IsEnabled { get; set; }
+
+ ///
+ /// Channel-specific fields as JSON. Sensitive tokens are AES-256 encrypted at rest
+ /// via EF Core value converter (see AppDbContext).
+ ///
+ public string ConfigJson { get; set; } = "{}";
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/AmtocBots.Api/Models/ChatRoom.cs b/src/AmtocBots.Api/Models/ChatRoom.cs
new file mode 100644
index 0000000..b79668b
--- /dev/null
+++ b/src/AmtocBots.Api/Models/ChatRoom.cs
@@ -0,0 +1,44 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class ChatRoom
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public bool IsGlobal { get; set; }
+ public Guid CreatedBy { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public ICollection Members { get; set; } = [];
+ public ICollection Messages { get; set; } = [];
+}
+
+public sealed class ChatRoomMember
+{
+ public Guid RoomId { get; set; }
+ public ChatRoom? Room { get; set; }
+
+ /// user | bot
+ public string MemberType { get; set; } = "user";
+ public Guid MemberId { get; set; }
+ public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class ChatMessage
+{
+ public Guid Id { get; set; }
+ public Guid RoomId { get; set; }
+ public ChatRoom? Room { get; set; }
+
+ /// user | bot
+ public string SenderType { get; set; } = "user";
+ public Guid SenderId { get; set; }
+
+ public string Content { get; set; } = string.Empty;
+ public Guid[] Mentions { get; set; } = [];
+ public Guid? ReplyToId { get; set; }
+ public ChatMessage? ReplyTo { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset? EditedAt { get; set; }
+}
diff --git a/src/AmtocBots.Api/Models/KanbanBoard.cs b/src/AmtocBots.Api/Models/KanbanBoard.cs
new file mode 100644
index 0000000..287746a
--- /dev/null
+++ b/src/AmtocBots.Api/Models/KanbanBoard.cs
@@ -0,0 +1,51 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class KanbanBoard
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public Guid CreatedBy { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public ICollection Columns { get; set; } = [];
+}
+
+public sealed class KanbanColumn
+{
+ public Guid Id { get; set; }
+ public Guid BoardId { get; set; }
+ public KanbanBoard? Board { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public int Position { get; set; }
+ public string? Color { get; set; }
+ public int? WipLimit { get; set; }
+
+ public ICollection Cards { get; set; } = [];
+}
+
+public sealed class KanbanCard
+{
+ public Guid Id { get; set; }
+ public Guid ColumnId { get; set; }
+ public KanbanColumn? Column { get; set; }
+ public Guid BoardId { get; set; }
+
+ public string Title { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? Priority { get; set; } // low | medium | high | critical
+ public string[] Labels { get; set; } = [];
+ public DateTimeOffset? DueDate { get; set; }
+ public int Position { get; set; }
+
+ public Guid? AssignedInstanceId { get; set; }
+ public BotInstance? AssignedInstance { get; set; }
+ public Guid? AssignedUserId { get; set; }
+
+ /// human | bot
+ public string CreatedByType { get; set; } = "human";
+ public Guid CreatedById { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/AmtocBots.Api/Models/ModelSwitchRule.cs b/src/AmtocBots.Api/Models/ModelSwitchRule.cs
new file mode 100644
index 0000000..0adc21c
--- /dev/null
+++ b/src/AmtocBots.Api/Models/ModelSwitchRule.cs
@@ -0,0 +1,23 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class ModelSwitchRule
+{
+ public Guid Id { get; set; }
+ public Guid InstanceId { get; set; }
+ public BotInstance? Instance { get; set; }
+
+ /// threshold | cron | manual
+ public string RuleType { get; set; } = "threshold";
+
+ // Threshold rules
+ public string? TriggerModel { get; set; }
+ public int? ThresholdPct { get; set; }
+
+ // Cron rules
+ public string? CronExpression { get; set; }
+
+ public string TargetModel { get; set; } = string.Empty;
+ public bool IsActive { get; set; } = true;
+ public int Priority { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/src/AmtocBots.Api/Models/TokenUsageRecord.cs b/src/AmtocBots.Api/Models/TokenUsageRecord.cs
new file mode 100644
index 0000000..328706e
--- /dev/null
+++ b/src/AmtocBots.Api/Models/TokenUsageRecord.cs
@@ -0,0 +1,15 @@
+namespace AmtocBots.Api.Models;
+
+public sealed class TokenUsageRecord
+{
+ public Guid Id { get; set; }
+ public Guid InstanceId { get; set; }
+ public BotInstance? Instance { get; set; }
+
+ public string Model { get; set; } = string.Empty;
+ public DateOnly UsageDate { get; set; } = DateOnly.FromDateTime(DateTime.UtcNow);
+ public long PromptTokens { get; set; }
+ public long CompletionTokens { get; set; }
+ public long TotalTokens { get; set; }
+ public decimal? EstimatedCostUsd { get; set; }
+}
diff --git a/src/AmtocBots.Api/Program.cs b/src/AmtocBots.Api/Program.cs
new file mode 100644
index 0000000..c96d723
--- /dev/null
+++ b/src/AmtocBots.Api/Program.cs
@@ -0,0 +1,125 @@
+using AmtocBots.Api.BackgroundServices;
+using AmtocBots.Api.Configuration;
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Endpoints;
+using AmtocBots.Api.Hubs;
+using AmtocBots.Api.Services.Docker;
+using AmtocBots.Api.Services.Models;
+using AmtocBots.Api.Services.Ollama;
+using AmtocBots.Api.Services.OpenClaw;
+using AmtocBots.Api.Services.Queue;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.Tokens;
+using Pgvector.EntityFrameworkCore;
+using StackExchange.Redis;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// ── Options ───────────────────────────────────────────────────────────────────
+builder.Services.Configure(builder.Configuration.GetSection("Docker"));
+builder.Services.Configure(builder.Configuration.GetSection("Ollama"));
+builder.Services.Configure(builder.Configuration.GetSection("Encryption"));
+
+// ── Database ──────────────────────────────────────────────────────────────────
+builder.Services.AddDbContext(opt =>
+ opt.UseNpgsql(
+ builder.Configuration.GetConnectionString("Default"),
+ o => o.UseVector()));
+
+// ── Redis ─────────────────────────────────────────────────────────────────────
+var redisConn = builder.Configuration["Redis:ConnectionString"]
+ ?? throw new InvalidOperationException("Redis:ConnectionString not configured");
+builder.Services.AddSingleton(_ => ConnectionMultiplexer.Connect(redisConn));
+builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redisConn);
+
+// ── Auth (Keycloak JWT) ───────────────────────────────────────────────────────
+var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
+ ?? throw new InvalidOperationException("Keycloak:Authority not configured");
+var keycloakAudience = builder.Configuration["Keycloak:Audience"] ?? "amtocbots-api";
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(opt =>
+ {
+ opt.Authority = keycloakAuthority;
+ opt.Audience = keycloakAudience;
+ opt.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+ opt.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ NameClaimType = "preferred_username",
+ RoleClaimType = "realm_access.roles",
+ };
+ // Allow JWT in SignalR query string
+ opt.Events = new JwtBearerEvents
+ {
+ OnMessageReceived = ctx =>
+ {
+ var token = ctx.Request.Query["access_token"];
+ if (!string.IsNullOrEmpty(token) &&
+ ctx.HttpContext.Request.Path.StartsWithSegments("/hubs"))
+ ctx.Token = token;
+ return Task.CompletedTask;
+ },
+ };
+ });
+
+builder.Services.AddAuthorizationBuilder()
+ .AddPolicy("AdminOnly", p => p.RequireRole("admin"))
+ .AddPolicy("Operator", p => p.RequireRole("admin", "operator"));
+
+// ── Services ──────────────────────────────────────────────────────────────────
+builder.Services.AddSingleton();
+builder.Services.AddHttpClient();
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp => sp.GetRequiredService());
+builder.Services.AddHttpClient(c =>
+ c.BaseAddress = new Uri(builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434"));
+
+// ── Background Services ───────────────────────────────────────────────────────
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+
+// ── SignalR ───────────────────────────────────────────────────────────────────
+builder.Services.AddSignalR();
+
+// ── Controllers + API Explorer ────────────────────────────────────────────────
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+
+// ── CORS ──────────────────────────────────────────────────────────────────────
+var corsOrigins = builder.Configuration["Cors:Origins"]?.Split(',') ?? ["http://localhost:4200"];
+builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
+ p.WithOrigins(corsOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
+
+var app = builder.Build();
+
+// ── Migrate on startup ────────────────────────────────────────────────────────
+await using (var scope = app.Services.CreateAsyncScope())
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync();
+}
+
+// ── Middleware ────────────────────────────────────────────────────────────────
+app.UseCors();
+app.UseAuthentication();
+app.UseAuthorization();
+
+// ── Routes ────────────────────────────────────────────────────────────────────
+app.MapControllers();
+app.MapHub("/hubs/instances");
+app.MapHub("/hubs/kanban");
+app.MapHub("/hubs/chat");
+
+app.MapGroup("/api/health").MapHealthEndpoints();
+app.MapGroup("/api/channels").MapChannelEndpoints().RequireAuthorization();
+app.MapGroup("/api/ollama").MapOllamaEndpoints().RequireAuthorization();
+
+app.Run();
diff --git a/src/AmtocBots.Api/Services/Docker/DockerService.cs b/src/AmtocBots.Api/Services/Docker/DockerService.cs
new file mode 100644
index 0000000..2fa385d
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Docker/DockerService.cs
@@ -0,0 +1,167 @@
+using AmtocBots.Api.Configuration;
+using AmtocBots.Api.Models;
+using Docker.DotNet;
+using Docker.DotNet.Models;
+using Microsoft.Extensions.Options;
+
+namespace AmtocBots.Api.Services.Docker;
+
+public sealed class DockerService : IDockerService, IDisposable
+{
+ private readonly DockerClient _client;
+ private readonly DockerOptions _opts;
+ private readonly ILogger _log;
+
+ public DockerService(IOptions opts, ILogger log)
+ {
+ _opts = opts.Value;
+ _log = log;
+ _client = new DockerClientConfiguration(new Uri($"unix://{_opts.SocketPath}"))
+ .CreateClient();
+ }
+
+ public async Task CreateAndStartContainerAsync(BotInstance instance, CancellationToken ct = default)
+ {
+ var containerName = instance.ContainerName;
+ var volumeName = $"openclaw-config-{instance.Id}";
+
+ // Ensure config volume exists
+ await _client.Volumes.CreateAsync(new VolumesCreateParameters { Name = volumeName }, ct);
+
+ var hostConfig = new HostConfig
+ {
+ NetworkMode = _opts.OpenClawNetwork,
+ Binds = [$"{volumeName}:/root/.openclaw"],
+ RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped },
+ };
+
+ if (instance.MemoryLimitMb.HasValue)
+ hostConfig.Memory = instance.MemoryLimitMb.Value * 1024L * 1024L;
+
+ if (instance.CpuLimit.HasValue)
+ hostConfig.NanoCPUs = (long)(instance.CpuLimit.Value * 1e9);
+
+ var create = await _client.Containers.CreateContainerAsync(new CreateContainerParameters
+ {
+ Name = containerName,
+ Image = _opts.OpenClawImage,
+ HostConfig = hostConfig,
+ Labels = new Dictionary
+ {
+ ["amtocbots.managed"] = "true",
+ ["amtocbots.instance-id"] = instance.Id.ToString(),
+ },
+ }, ct);
+
+ await _client.Containers.StartContainerAsync(create.ID, new ContainerStartParameters(), ct);
+ _log.LogInformation("Started container {Name} ({Id})", containerName, create.ID);
+ return create.ID;
+ }
+
+ public async Task StopContainerAsync(string containerId, CancellationToken ct = default)
+ {
+ await _client.Containers.StopContainerAsync(containerId,
+ new ContainerStopParameters { WaitBeforeKillSeconds = 10 }, ct);
+ }
+
+ public async Task RestartContainerAsync(string containerId, CancellationToken ct = default)
+ {
+ await _client.Containers.RestartContainerAsync(containerId,
+ new ContainerRestartParameters { WaitBeforeKillSeconds = 10 }, ct);
+ }
+
+ public async Task RemoveContainerAsync(string containerId, bool force = true, CancellationToken ct = default)
+ {
+ await _client.Containers.RemoveContainerAsync(containerId,
+ new ContainerRemoveParameters { Force = force, RemoveVolumes = false }, ct);
+ }
+
+ public async Task GetStatsAsync(Guid instanceId, string containerId, CancellationToken ct = default)
+ {
+ try
+ {
+ var inspect = await _client.Containers.InspectContainerAsync(containerId, ct);
+ var status = inspect.State.Running ? "running" : inspect.State.Status;
+
+ // Stats stream — read one sample then cancel
+ double cpuPct = 0;
+ long memUsage = 0, memLimit = 0;
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ await _client.Containers.GetContainerStatsAsync(containerId,
+ new ContainerStatsParameters { Stream = false },
+ new Progress(s =>
+ {
+ cpuPct = CalculateCpuPercent(s);
+ memUsage = (long)(s.MemoryStats.Usage ?? 0) / (1024 * 1024);
+ memLimit = (long)(s.MemoryStats.Limit ?? 0) / (1024 * 1024);
+ cts.Cancel();
+ }), cts.Token);
+
+ return new ContainerStats(instanceId, status, cpuPct, memUsage, memLimit);
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Could not get stats for container {Id}", containerId);
+ return null;
+ }
+ }
+
+ public async Task> GetAllManagedStatsAsync(
+ IEnumerable<(Guid Id, string ContainerId)> instances, CancellationToken ct = default)
+ {
+ var tasks = instances.Select(i => GetStatsAsync(i.Id, i.ContainerId, ct));
+ var results = await Task.WhenAll(tasks);
+ return results.OfType().ToList();
+ }
+
+ public async Task GetLogsAsync(string containerId, int tailLines = 200, CancellationToken ct = default)
+ {
+ var stream = await _client.Containers.GetContainerLogsAsync(containerId,
+ false,
+ new ContainerLogsParameters
+ {
+ ShowStdout = true,
+ ShowStderr = true,
+ Tail = tailLines.ToString(),
+ Timestamps = true,
+ }, ct);
+
+ var (stdout, _) = await stream.ReadOutputToEndAsync(ct);
+ return stdout;
+ }
+
+ public async Task WriteConfigVolumeAsync(Guid instanceId, string json5Content, CancellationToken ct = default)
+ {
+ // Write the JSON5 config into the named volume via a temporary busybox container
+ var volumeName = $"openclaw-config-{instanceId}";
+ var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json5Content));
+
+ var create = await _client.Containers.CreateContainerAsync(new CreateContainerParameters
+ {
+ Image = "busybox",
+ Cmd = ["sh", "-c", $"echo {encoded} | base64 -d > /data/openclaw.json"],
+ HostConfig = new HostConfig
+ {
+ Binds = [$"{volumeName}:/data"],
+ AutoRemove = true,
+ },
+ }, ct);
+
+ await _client.Containers.StartContainerAsync(create.ID, new ContainerStartParameters(), ct);
+
+ // Wait for the helper container to finish
+ await _client.Containers.WaitContainerAsync(create.ID, ct);
+ _log.LogDebug("Config written to volume {Volume} for instance {Id}", volumeName, instanceId);
+ }
+
+ private static double CalculateCpuPercent(ContainerStatsResponse s)
+ {
+ var cpuDelta = (double)(s.CPUStats.CPUUsage.TotalUsage - s.PreCPUStats.CPUUsage.TotalUsage);
+ var systemDelta = (double)(s.CPUStats.SystemUsage - s.PreCPUStats.SystemUsage);
+ var numCpus = s.CPUStats.OnlineCPUs > 0 ? s.CPUStats.OnlineCPUs : (ulong)Environment.ProcessorCount;
+ return systemDelta > 0 ? cpuDelta / systemDelta * numCpus * 100.0 : 0;
+ }
+
+ public void Dispose() => _client.Dispose();
+}
diff --git a/src/AmtocBots.Api/Services/Docker/IDockerService.cs b/src/AmtocBots.Api/Services/Docker/IDockerService.cs
new file mode 100644
index 0000000..190505a
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Docker/IDockerService.cs
@@ -0,0 +1,22 @@
+using AmtocBots.Api.Models;
+
+namespace AmtocBots.Api.Services.Docker;
+
+public sealed record ContainerStats(
+ Guid InstanceId,
+ string Status,
+ double CpuPercent,
+ long MemoryUsageMb,
+ long MemoryLimitMb);
+
+public interface IDockerService
+{
+ Task CreateAndStartContainerAsync(BotInstance instance, CancellationToken ct = default);
+ Task StopContainerAsync(string containerId, CancellationToken ct = default);
+ Task RestartContainerAsync(string containerId, CancellationToken ct = default);
+ Task RemoveContainerAsync(string containerId, bool force = true, CancellationToken ct = default);
+ Task GetStatsAsync(Guid instanceId, string containerId, CancellationToken ct = default);
+ Task> GetAllManagedStatsAsync(IEnumerable<(Guid Id, string ContainerId)> instances, CancellationToken ct = default);
+ Task GetLogsAsync(string containerId, int tailLines = 200, CancellationToken ct = default);
+ Task WriteConfigVolumeAsync(Guid instanceId, string json5Content, CancellationToken ct = default);
+}
diff --git a/src/AmtocBots.Api/Services/Models/IModelSwitchingService.cs b/src/AmtocBots.Api/Services/Models/IModelSwitchingService.cs
new file mode 100644
index 0000000..63720ff
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Models/IModelSwitchingService.cs
@@ -0,0 +1,7 @@
+namespace AmtocBots.Api.Services.Models;
+
+public interface IModelSwitchingService
+{
+ Task EvaluateThresholdRulesAsync(Guid instanceId, CancellationToken ct = default);
+ Task SwitchModelAsync(Guid instanceId, string targetModel, CancellationToken ct = default);
+}
diff --git a/src/AmtocBots.Api/Services/Models/ITokenTracker.cs b/src/AmtocBots.Api/Services/Models/ITokenTracker.cs
new file mode 100644
index 0000000..02a75ae
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Models/ITokenTracker.cs
@@ -0,0 +1,15 @@
+namespace AmtocBots.Api.Services.Models;
+
+public sealed record TokenUsageSample(
+ Guid InstanceId,
+ string Model,
+ long PromptTokens,
+ long CompletionTokens,
+ decimal? CostUsd = null);
+
+public interface ITokenTracker
+{
+ Task RecordAsync(TokenUsageSample sample, CancellationToken ct = default);
+ Task<(long Prompt, long Completion, long Total)> GetTodayTotalsAsync(Guid instanceId, string model, CancellationToken ct = default);
+ Task> GetAllModelTotalsForInstanceAsync(Guid instanceId, DateOnly date, CancellationToken ct = default);
+}
diff --git a/src/AmtocBots.Api/Services/Models/ModelSwitchingService.cs b/src/AmtocBots.Api/Services/Models/ModelSwitchingService.cs
new file mode 100644
index 0000000..f11a0fa
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Models/ModelSwitchingService.cs
@@ -0,0 +1,86 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Services.Docker;
+using AmtocBots.Api.Services.OpenClaw;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Services.Models;
+
+public sealed class ModelSwitchingService(
+ AppDbContext db,
+ IDockerService docker,
+ OpenClawConfigBuilder configBuilder,
+ ILogger log) : IModelSwitchingService
+{
+ // Daily token limits per model provider (configurable, sensible defaults)
+ private static readonly Dictionary DailyTokenLimits = new()
+ {
+ ["anthropic/"] = 1_000_000,
+ ["openai/"] = 1_000_000,
+ ["google/"] = 1_000_000,
+ };
+
+ public async Task EvaluateThresholdRulesAsync(Guid instanceId, CancellationToken ct = default)
+ {
+ var rules = await db.SwitchRules
+ .Where(r => r.InstanceId == instanceId && r.IsActive && r.RuleType == "threshold")
+ .OrderByDescending(r => r.Priority)
+ .ToListAsync(ct);
+
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ foreach (var rule in rules)
+ {
+ if (rule.TriggerModel is null || rule.ThresholdPct is null) continue;
+
+ var usage = await db.TokenUsage
+ .Where(u => u.InstanceId == instanceId && u.Model == rule.TriggerModel && u.UsageDate == today)
+ .FirstOrDefaultAsync(ct);
+
+ if (usage is null) continue;
+
+ var limit = GetDailyLimit(rule.TriggerModel);
+ var usedPct = (int)(usage.TotalTokens * 100 / limit);
+
+ if (usedPct >= rule.ThresholdPct)
+ {
+ log.LogInformation("Instance {Id}: model {Model} at {Pct}% — switching to {Target}",
+ instanceId, rule.TriggerModel, usedPct, rule.TargetModel);
+ await SwitchModelAsync(instanceId, rule.TargetModel, ct);
+ return; // highest-priority rule wins
+ }
+ }
+ }
+
+ public async Task SwitchModelAsync(Guid instanceId, string targetModel, CancellationToken ct = default)
+ {
+ var instance = await db.Instances
+ .Include(i => i.ChannelConfigs)
+ .FirstOrDefaultAsync(i => i.Id == instanceId, ct)
+ ?? throw new InvalidOperationException($"Instance {instanceId} not found");
+
+ if (instance.CurrentModel == targetModel) return;
+
+ instance.CurrentModel = targetModel;
+ instance.UpdatedAt = DateTimeOffset.UtcNow;
+
+ // Rebuild and write config
+ var token = instance.ApiBearerTokenHash ?? string.Empty; // actual token stored separately
+ var config = configBuilder.Build(instance, instance.ChannelConfigs, token);
+ await docker.WriteConfigVolumeAsync(instanceId, config, ct);
+
+ // Restart container to pick up new model
+ if (instance.ContainerId is not null)
+ await docker.RestartContainerAsync(instance.ContainerId, ct);
+
+ await db.SaveChangesAsync(ct);
+ log.LogInformation("Instance {Id} switched to model {Model}", instanceId, targetModel);
+ }
+
+ private static long GetDailyLimit(string model)
+ {
+ foreach (var (prefix, limit) in DailyTokenLimits)
+ if (model.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ return limit;
+ return long.MaxValue; // Ollama / unknown → no limit
+ }
+}
diff --git a/src/AmtocBots.Api/Services/Models/TokenTracker.cs b/src/AmtocBots.Api/Services/Models/TokenTracker.cs
new file mode 100644
index 0000000..3a3b902
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Models/TokenTracker.cs
@@ -0,0 +1,59 @@
+using AmtocBots.Api.Data;
+using AmtocBots.Api.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace AmtocBots.Api.Services.Models;
+
+public sealed class TokenTracker(AppDbContext db) : ITokenTracker
+{
+ public async Task RecordAsync(TokenUsageSample sample, CancellationToken ct = default)
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ // Upsert — increment today's record
+ var existing = await db.TokenUsage
+ .FirstOrDefaultAsync(r => r.InstanceId == sample.InstanceId
+ && r.Model == sample.Model
+ && r.UsageDate == today, ct);
+ if (existing is null)
+ {
+ db.TokenUsage.Add(new TokenUsageRecord
+ {
+ InstanceId = sample.InstanceId,
+ Model = sample.Model,
+ UsageDate = today,
+ PromptTokens = sample.PromptTokens,
+ CompletionTokens = sample.CompletionTokens,
+ TotalTokens = sample.PromptTokens + sample.CompletionTokens,
+ EstimatedCostUsd = sample.CostUsd,
+ });
+ }
+ else
+ {
+ existing.PromptTokens += sample.PromptTokens;
+ existing.CompletionTokens += sample.CompletionTokens;
+ existing.TotalTokens += sample.PromptTokens + sample.CompletionTokens;
+ if (sample.CostUsd.HasValue)
+ existing.EstimatedCostUsd = (existing.EstimatedCostUsd ?? 0) + sample.CostUsd.Value;
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task<(long Prompt, long Completion, long Total)> GetTodayTotalsAsync(
+ Guid instanceId, string model, CancellationToken ct = default)
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var rec = await db.TokenUsage
+ .FirstOrDefaultAsync(r => r.InstanceId == instanceId && r.Model == model && r.UsageDate == today, ct);
+ return rec is null ? (0, 0, 0) : (rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens);
+ }
+
+ public async Task> GetAllModelTotalsForInstanceAsync(
+ Guid instanceId, DateOnly date, CancellationToken ct = default)
+ {
+ return await db.TokenUsage
+ .Where(r => r.InstanceId == instanceId && r.UsageDate == date)
+ .ToDictionaryAsync(r => r.Model, r => r.TotalTokens, ct);
+ }
+}
diff --git a/src/AmtocBots.Api/Services/Ollama/IOllamaService.cs b/src/AmtocBots.Api/Services/Ollama/IOllamaService.cs
new file mode 100644
index 0000000..be4658f
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Ollama/IOllamaService.cs
@@ -0,0 +1,11 @@
+namespace AmtocBots.Api.Services.Ollama;
+
+public sealed record OllamaModel(string Name, string? Size, DateTimeOffset? ModifiedAt);
+
+public interface IOllamaService
+{
+ Task> ListModelsAsync(CancellationToken ct = default);
+ Task PullModelAsync(string modelName, CancellationToken ct = default);
+ Task IsHealthyAsync(CancellationToken ct = default);
+ Task GenerateEmbeddingAsync(string text, string model = "nomic-embed-text", CancellationToken ct = default);
+}
diff --git a/src/AmtocBots.Api/Services/Ollama/OllamaService.cs b/src/AmtocBots.Api/Services/Ollama/OllamaService.cs
new file mode 100644
index 0000000..834e0c8
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Ollama/OllamaService.cs
@@ -0,0 +1,58 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AmtocBots.Api.Services.Ollama;
+
+public sealed class OllamaService(HttpClient http, ILogger log) : IOllamaService
+{
+ public async Task> ListModelsAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var resp = await http.GetFromJsonAsync("/api/tags", ct);
+ return resp?.Models.Select(m => new OllamaModel(m.Name, m.Size, m.ModifiedAt)).ToList()
+ ?? [];
+ }
+ catch (Exception ex)
+ {
+ log.LogWarning(ex, "Failed to list Ollama models");
+ return [];
+ }
+ }
+
+ public async Task PullModelAsync(string modelName, CancellationToken ct = default)
+ {
+ var body = new StringContent(JsonSerializer.Serialize(new { name = modelName }), Encoding.UTF8, "application/json");
+ using var resp = await http.PostAsync("/api/pull", body, ct);
+ resp.EnsureSuccessStatusCode();
+ }
+
+ public async Task IsHealthyAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var resp = await http.GetAsync("/", ct);
+ return resp.IsSuccessStatusCode;
+ }
+ catch { return false; }
+ }
+
+ public async Task GenerateEmbeddingAsync(string text, string model = "nomic-embed-text", CancellationToken ct = default)
+ {
+ var payload = new StringContent(
+ JsonSerializer.Serialize(new { model, prompt = text }),
+ Encoding.UTF8, "application/json");
+ var resp = await http.PostAsync("/api/embeddings", payload, ct);
+ resp.EnsureSuccessStatusCode();
+ var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct);
+ return result?.Embedding ?? [];
+ }
+
+ private sealed record OllamaListResponse([property: JsonPropertyName("models")] List Models);
+ private sealed record OllamaModelDto(
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("size")] string? Size,
+ [property: JsonPropertyName("modified_at")] DateTimeOffset? ModifiedAt);
+ private sealed record OllamaEmbeddingResponse([property: JsonPropertyName("embedding")] float[] Embedding);
+}
diff --git a/src/AmtocBots.Api/Services/OpenClaw/IOpenClawClient.cs b/src/AmtocBots.Api/Services/OpenClaw/IOpenClawClient.cs
new file mode 100644
index 0000000..c6b6132
--- /dev/null
+++ b/src/AmtocBots.Api/Services/OpenClaw/IOpenClawClient.cs
@@ -0,0 +1,16 @@
+namespace AmtocBots.Api.Services.OpenClaw;
+
+public sealed record AgentRequest(
+ string Description,
+ string? Model = null,
+ string? Channel = null);
+
+public sealed record QrCodeResponse(byte[] ImageBytes, string ContentType);
+
+public interface IOpenClawClient
+{
+ Task WakeAsync(string baseUrl, string bearerToken, string description, CancellationToken ct = default);
+ Task RunAgentAsync(string baseUrl, string bearerToken, AgentRequest request, CancellationToken ct = default);
+ Task GetWhatsAppQrAsync(string baseUrl, string bearerToken, CancellationToken ct = default);
+ Task IsHealthyAsync(string baseUrl, CancellationToken ct = default);
+}
diff --git a/src/AmtocBots.Api/Services/OpenClaw/OpenClawClient.cs b/src/AmtocBots.Api/Services/OpenClaw/OpenClawClient.cs
new file mode 100644
index 0000000..2b3456e
--- /dev/null
+++ b/src/AmtocBots.Api/Services/OpenClaw/OpenClawClient.cs
@@ -0,0 +1,70 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+
+namespace AmtocBots.Api.Services.OpenClaw;
+
+public sealed class OpenClawClient(HttpClient http, ILogger log) : IOpenClawClient
+{
+ public async Task WakeAsync(string baseUrl, string bearerToken, string description, CancellationToken ct = default)
+ {
+ using var req = BuildRequest(HttpMethod.Post, baseUrl, "/hooks/wake", bearerToken);
+ req.Content = JsonContent(new { description, trigger = "now" });
+ var resp = await http.SendAsync(req, ct);
+ resp.EnsureSuccessStatusCode();
+ }
+
+ public async Task RunAgentAsync(string baseUrl, string bearerToken, AgentRequest request, CancellationToken ct = default)
+ {
+ using var req = BuildRequest(HttpMethod.Post, baseUrl, "/hooks/agent", bearerToken);
+ req.Content = JsonContent(new
+ {
+ description = request.Description,
+ model = request.Model,
+ channel = request.Channel,
+ });
+ var resp = await http.SendAsync(req, ct);
+ resp.EnsureSuccessStatusCode();
+ }
+
+ public async Task GetWhatsAppQrAsync(string baseUrl, string bearerToken, CancellationToken ct = default)
+ {
+ try
+ {
+ using var req = BuildRequest(HttpMethod.Get, baseUrl, "/whatsapp/qr", bearerToken);
+ var resp = await http.SendAsync(req, ct);
+ if (!resp.IsSuccessStatusCode) return null;
+ var bytes = await resp.Content.ReadAsByteArrayAsync(ct);
+ var ct2 = resp.Content.Headers.ContentType?.MediaType ?? "image/png";
+ return new QrCodeResponse(bytes, ct2);
+ }
+ catch (Exception ex)
+ {
+ log.LogWarning(ex, "Failed to fetch WhatsApp QR from {Url}", baseUrl);
+ return null;
+ }
+ }
+
+ public async Task IsHealthyAsync(string baseUrl, CancellationToken ct = default)
+ {
+ try
+ {
+ var resp = await http.GetAsync($"{baseUrl.TrimEnd('/')}/health", ct);
+ return resp.IsSuccessStatusCode;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static HttpRequestMessage BuildRequest(HttpMethod method, string baseUrl, string path, string token)
+ {
+ var req = new HttpRequestMessage(method, $"{baseUrl.TrimEnd('/')}{path}");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return req;
+ }
+
+ private static StringContent JsonContent(object payload) =>
+ new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
+}
diff --git a/src/AmtocBots.Api/Services/OpenClaw/OpenClawConfigBuilder.cs b/src/AmtocBots.Api/Services/OpenClaw/OpenClawConfigBuilder.cs
new file mode 100644
index 0000000..93bf544
--- /dev/null
+++ b/src/AmtocBots.Api/Services/OpenClaw/OpenClawConfigBuilder.cs
@@ -0,0 +1,104 @@
+using AmtocBots.Api.Models;
+using System.Text;
+using System.Text.Json;
+
+namespace AmtocBots.Api.Services.OpenClaw;
+
+///
+/// Builds an OpenClaw JSON5 configuration from a BotInstance + its ChannelConfigs.
+/// The resulting string is written to the Docker volume via DockerService.WriteConfigVolumeAsync.
+///
+public sealed class OpenClawConfigBuilder
+{
+ public string Build(BotInstance instance, IEnumerable channels, string bearerToken)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("// Auto-generated by AmtocBots Manager. Edit via the UI.");
+ sb.AppendLine("{");
+ sb.AppendLine($" identity: {{ name: {Json(instance.Name)}, theme: \"helpful assistant\", emoji: \"🤖\" }},");
+ sb.AppendLine(" agent: {");
+ sb.AppendLine(" workspace: \"/root/.openclaw/workspace\",");
+ sb.AppendLine(" model: {");
+ sb.AppendLine($" primary: {Json(instance.CurrentModel)},");
+ sb.AppendLine(" },");
+ sb.AppendLine(" },");
+ sb.AppendLine(" channels: {");
+
+ foreach (var ch in channels.Where(c => c.IsEnabled))
+ {
+ var cfg = JsonDocument.Parse(ch.ConfigJson).RootElement;
+ switch (ch.ChannelType)
+ {
+ case "telegram":
+ AppendTelegram(sb, cfg);
+ break;
+ case "whatsapp":
+ AppendWhatsApp(sb, cfg);
+ break;
+ case "discord":
+ AppendDiscord(sb, cfg);
+ break;
+ case "slack":
+ AppendSlack(sb, cfg);
+ break;
+ }
+ }
+
+ sb.AppendLine(" },");
+ sb.AppendLine(" gateway: {");
+ sb.AppendLine(" port: 18789,");
+ sb.AppendLine(" auth: {");
+ sb.AppendLine($" tokens: [{Json(bearerToken)}],");
+ sb.AppendLine(" },");
+ sb.AppendLine(" },");
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static void AppendTelegram(StringBuilder sb, JsonElement cfg)
+ {
+ sb.AppendLine(" telegram: {");
+ sb.AppendLine($" token: {Json(cfg.TryGet("token"))},");
+ if (cfg.TryGetProperty("allowFrom", out var af))
+ sb.AppendLine($" allowFrom: {af},");
+ sb.AppendLine(" },");
+ }
+
+ private static void AppendWhatsApp(StringBuilder sb, JsonElement cfg)
+ {
+ sb.AppendLine(" whatsapp: {");
+ if (cfg.TryGetProperty("allowFrom", out var af))
+ sb.AppendLine($" allowFrom: {af},");
+ sb.AppendLine(" },");
+ }
+
+ private static void AppendDiscord(StringBuilder sb, JsonElement cfg)
+ {
+ sb.AppendLine(" discord: {");
+ sb.AppendLine($" token: {Json(cfg.TryGet("token"))},");
+ if (cfg.TryGetProperty("guildId", out var g))
+ sb.AppendLine($" guildId: {Json(g.GetString())},");
+ if (cfg.TryGetProperty("allowChannels", out var ac))
+ sb.AppendLine($" allowChannels: {ac},");
+ sb.AppendLine(" },");
+ }
+
+ private static void AppendSlack(StringBuilder sb, JsonElement cfg)
+ {
+ sb.AppendLine(" slack: {");
+ sb.AppendLine($" appToken: {Json(cfg.TryGet("appToken"))},");
+ sb.AppendLine($" botToken: {Json(cfg.TryGet("botToken"))},");
+ if (cfg.TryGetProperty("allowChannels", out var ac))
+ sb.AppendLine($" allowChannels: {ac},");
+ sb.AppendLine(" },");
+ }
+
+ private static string Json(string? value) =>
+ value is null ? "null" : JsonSerializer.Serialize(value);
+}
+
+file static class JsonElementExtensions
+{
+ public static string? TryGet(this JsonElement el, string prop) =>
+ el.TryGetProperty(prop, out var v) ? v.GetString() : null;
+}
diff --git a/src/AmtocBots.Api/Services/Queue/IMessageQueueService.cs b/src/AmtocBots.Api/Services/Queue/IMessageQueueService.cs
new file mode 100644
index 0000000..41fdef2
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Queue/IMessageQueueService.cs
@@ -0,0 +1,16 @@
+namespace AmtocBots.Api.Services.Queue;
+
+public sealed record QueuedAgentMessage(
+ Guid InstanceId,
+ string BaseUrl,
+ string BearerToken,
+ string Description,
+ string? Model,
+ int RetryCount = 0);
+
+public interface IMessageQueueService
+{
+ Task EnqueueAsync(QueuedAgentMessage message, CancellationToken ct = default);
+ Task DequeueAsync(CancellationToken ct = default);
+ Task RequeueWithDelayAsync(QueuedAgentMessage message, TimeSpan delay, CancellationToken ct = default);
+}
diff --git a/src/AmtocBots.Api/Services/Queue/RedisMessageQueueService.cs b/src/AmtocBots.Api/Services/Queue/RedisMessageQueueService.cs
new file mode 100644
index 0000000..2b7a894
--- /dev/null
+++ b/src/AmtocBots.Api/Services/Queue/RedisMessageQueueService.cs
@@ -0,0 +1,46 @@
+using StackExchange.Redis;
+using System.Text.Json;
+
+namespace AmtocBots.Api.Services.Queue;
+
+public sealed class RedisMessageQueueService(IConnectionMultiplexer redis) : IMessageQueueService
+{
+ private const string QueueKey = "queue:agent-messages";
+
+ public async Task EnqueueAsync(QueuedAgentMessage message, CancellationToken ct = default)
+ {
+ var db = redis.GetDatabase();
+ var json = JsonSerializer.Serialize(message);
+ await db.ListLeftPushAsync(QueueKey, json);
+ }
+
+ public async Task DequeueAsync(CancellationToken ct = default)
+ {
+ var db = redis.GetDatabase();
+ var value = await db.ListRightPopAsync(QueueKey);
+ if (value.IsNullOrEmpty) return null;
+ return JsonSerializer.Deserialize(value!);
+ }
+
+ public async Task RequeueWithDelayAsync(QueuedAgentMessage message, TimeSpan delay, CancellationToken ct = default)
+ {
+ // Use a sorted set as a delay queue (score = scheduled timestamp)
+ var db = redis.GetDatabase();
+ var json = JsonSerializer.Serialize(message with { RetryCount = message.RetryCount + 1 });
+ var score = DateTimeOffset.UtcNow.Add(delay).ToUnixTimeSeconds();
+ await db.SortedSetAddAsync("queue:agent-delayed", json, score);
+ }
+
+ /// Move ready delayed messages back to the main queue. Called by QueueRetryWorker.
+ public async Task FlushReadyDelayedAsync(CancellationToken ct = default)
+ {
+ var db = redis.GetDatabase();
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var ready = await db.SortedSetRangeByScoreAsync("queue:agent-delayed", 0, now);
+ foreach (var item in ready)
+ {
+ await db.SortedSetRemoveAsync("queue:agent-delayed", item);
+ await db.ListLeftPushAsync(QueueKey, item);
+ }
+ }
+}
diff --git a/src/AmtocBots.Api/appsettings.Development.json b/src/AmtocBots.Api/appsettings.Development.json
new file mode 100644
index 0000000..b05b289
--- /dev/null
+++ b/src/AmtocBots.Api/appsettings.Development.json
@@ -0,0 +1,27 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "Microsoft.AspNetCore": "Information",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ }
+ },
+ "ConnectionStrings": {
+ "Default": "Host=localhost;Port=5432;Database=amtocbots;Username=amtocbots;Password=devpassword"
+ },
+ "Redis": {
+ "ConnectionString": "localhost:6379,password=devpassword"
+ },
+ "Keycloak": {
+ "Authority": "http://localhost:8180/realms/amtocbots"
+ },
+ "Ollama": {
+ "BaseUrl": "http://localhost:11434"
+ },
+ "Encryption": {
+ "Key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+ },
+ "Cors": {
+ "Origins": "http://localhost:4200"
+ }
+}
diff --git a/src/AmtocBots.Api/appsettings.json b/src/AmtocBots.Api/appsettings.json
new file mode 100644
index 0000000..1bbafec
--- /dev/null
+++ b/src/AmtocBots.Api/appsettings.json
@@ -0,0 +1,36 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Default": ""
+ },
+ "Redis": {
+ "ConnectionString": ""
+ },
+ "Keycloak": {
+ "Authority": "",
+ "Audience": "amtocbots-api"
+ },
+ "Docker": {
+ "SocketPath": "/var/run/docker.sock",
+ "OpenClawNetwork": "amtocbots_openclaw",
+ "OpenClawImage": "ghcr.io/openclaw/openclaw:latest",
+ "PortRangeStart": 18789,
+ "PortRangeEnd": 19789
+ },
+ "Ollama": {
+ "BaseUrl": "http://localhost:11434"
+ },
+ "Encryption": {
+ "Key": ""
+ },
+ "Cors": {
+ "Origins": "https://manager.amtocbot.com"
+ }
+}