diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs
new file mode 100644
index 0000000..3795b96
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs
@@ -0,0 +1,54 @@
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.OrgBoard.Domain;
+
+///
+/// The AI staffing an open seat: identity (name, monogram) + matched skill atoms + autonomy +
+/// the model config + docs. References Skills by key and the BYOK ApiConfig by id — never reaches
+/// into those modules' tables. One agent per seat.
+///
+internal sealed class Agent : Entity
+{
+ public Guid SeatId { get; private set; }
+ public string Name { get; private set; } = null!;
+ public string? Monogram { get; private set; }
+ public Autonomy Autonomy { get; private set; }
+ public Guid ApiConfigId { get; private set; }
+ public Guid? FallbackApiConfigId { get; private set; }
+ public List SkillKeys { get; private set; } = [];
+ public List Docs { get; private set; } = [];
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+ public DateTimeOffset UpdatedAtUtc { get; private set; }
+
+ private Agent()
+ {
+ }
+
+ public Agent(Guid seatId, DateTimeOffset createdAtUtc)
+ {
+ SeatId = seatId;
+ CreatedAtUtc = createdAtUtc;
+ UpdatedAtUtc = createdAtUtc;
+ }
+
+ public void Configure(
+ string name,
+ string? monogram,
+ Autonomy autonomy,
+ Guid apiConfigId,
+ Guid? fallbackApiConfigId,
+ List skillKeys,
+ List docs,
+ DateTimeOffset nowUtc)
+ {
+ Name = name;
+ Monogram = monogram;
+ Autonomy = autonomy;
+ ApiConfigId = apiConfigId;
+ FallbackApiConfigId = fallbackApiConfigId;
+ SkillKeys = skillKeys;
+ Docs = docs;
+ UpdatedAtUtc = nowUtc;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs
index 6ddd707..de5850c 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs
@@ -38,4 +38,11 @@ internal sealed class Seat : Entity
AgentId = null;
State = SeatState.Open;
}
+
+ public void AssignAgent(Guid agentId)
+ {
+ AgentId = agentId;
+ MemberId = null;
+ State = SeatState.Ai;
+ }
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs
index 77933a1..dd58e90 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs
@@ -1,4 +1,5 @@
using TeamUp.Modules.OrgBoard.Domain;
+using TeamUp.SharedKernel.Access;
namespace TeamUp.Modules.OrgBoard.Endpoints;
@@ -30,3 +31,27 @@ internal sealed record TaskResponse(
internal sealed record BoardColumn(string Status, IReadOnlyList Items);
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList Columns);
+
+internal sealed record CreateSeatRequest(Guid TeamId, string RoleName);
+
+internal sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
+
+internal sealed record ConfigureAgentRequest(
+ string Name,
+ string? Monogram,
+ Autonomy Autonomy,
+ Guid ApiConfigId,
+ Guid? FallbackApiConfigId,
+ List SkillKeys,
+ List Docs);
+
+internal sealed record AgentResponse(
+ Guid Id,
+ Guid SeatId,
+ string Name,
+ string? Monogram,
+ string Autonomy,
+ Guid ApiConfigId,
+ Guid? FallbackApiConfigId,
+ List SkillKeys,
+ List Docs);
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
index aa46d03..c6b0eeb 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
@@ -25,6 +25,11 @@ internal static class OrgBoardEndpoints
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
group.MapGet("/cartable", Cartable).RequireAuthorization();
+
+ group.MapPost("/seats", CreateSeat).RequireAuthorization();
+ group.MapGet("/seats", ListSeats).RequireAuthorization();
+ group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
+ group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
}
private static TaskResponse ToResponse(WorkItem item) => new(
@@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints
return (item, team, null);
}
+
+ private static SeatResponse ToSeat(Seat seat) =>
+ new(seat.Id, seat.TeamId, seat.RoleName, seat.State.ToString(), seat.MemberId, seat.AgentId);
+
+ private static AgentResponse ToAgent(Agent agent) => new(
+ agent.Id, agent.SeatId, agent.Name, agent.Monogram, agent.Autonomy.ToString(),
+ agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs);
+
+ private static async Task CreateSeat(
+ CreateSeatRequest request, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
+ if (team is null)
+ {
+ return Results.NotFound("Team not found.");
+ }
+
+ if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.RoleName))
+ {
+ return Results.BadRequest("RoleName is required.");
+ }
+
+ var seat = new Seat(team.Id, request.RoleName.Trim(), SeatState.Open, clock.GetUtcNow());
+ db.Seats.Add(seat);
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("seat.created", "Seat", seat.Id, user.MemberId, request.RoleName), ct);
+ return Results.Ok(ToSeat(seat));
+ }
+
+ private static async Task ListSeats(
+ Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
+ {
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct);
+ if (team is null)
+ {
+ return Results.NotFound("Team not found.");
+ }
+
+ if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ var seats = await db.Seats.Where(s => s.TeamId == teamId).OrderBy(s => s.CreatedAtUtc).ToListAsync(ct);
+ return Results.Ok(seats.Select(ToSeat).ToList());
+ }
+
+ private static async Task ConfigureAgent(
+ Guid id, ConfigureAgentRequest request, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct);
+ if (seat is null)
+ {
+ return Results.NotFound("Seat not found.");
+ }
+
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct);
+ if (team is null)
+ {
+ return Results.NotFound("Team not found.");
+ }
+
+ if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name) || request.ApiConfigId == Guid.Empty)
+ {
+ return Results.BadRequest("Name and apiConfigId are required.");
+ }
+
+ var now = clock.GetUtcNow();
+ var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct);
+ var isNew = agent is null;
+ agent ??= new Agent(seat.Id, now);
+ agent.Configure(
+ request.Name.Trim(), request.Monogram, request.Autonomy, request.ApiConfigId,
+ request.FallbackApiConfigId, request.SkillKeys ?? [], request.Docs ?? [], now);
+
+ if (isNew)
+ {
+ db.Agents.Add(agent);
+ }
+
+ seat.AssignAgent(agent.Id);
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(new AuditEvent("agent.configured", "Agent", agent.Id, user.MemberId, agent.Name), ct);
+ return Results.Ok(ToAgent(agent));
+ }
+
+ private static async Task GetAgent(
+ Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
+ {
+ var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct);
+ if (seat is null)
+ {
+ return Results.NotFound("Seat not found.");
+ }
+
+ var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct);
+ if (team is null)
+ {
+ return Results.NotFound("Team not found.");
+ }
+
+ if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct);
+ return agent is null
+ ? Results.NotFound("Seat has no agent configured.")
+ : Results.Ok(ToAgent(agent));
+ }
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs
new file mode 100644
index 0000000..0c93bf9
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs
@@ -0,0 +1,217 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeamUp.Modules.OrgBoard.Persistence;
+
+#nullable disable
+
+namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
+{
+ [DbContext(typeof(OrgBoardDbContext))]
+ [Migration("20260609200923_AddAgents")]
+ partial class AddAgents
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("orgboard")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiConfigId")
+ .HasColumnType("uuid");
+
+ b.Property("Autonomy")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.PrimitiveCollection>("Docs")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("FallbackApiConfigId")
+ .HasColumnType("uuid");
+
+ b.Property("Monogram")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("SeatId")
+ .HasColumnType("uuid");
+
+ b.PrimitiveCollection>("SkillKeys")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeatId")
+ .IsUnique();
+
+ b.ToTable("agents", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("Id");
+
+ b.ToTable("organizations", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AgentId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MemberId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleName")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TeamId");
+
+ b.ToTable("seats", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("teams", "orgboard");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AssigneeId")
+ .HasColumnType("uuid");
+
+ b.Property("AssigneeKind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TeamId");
+
+ b.HasIndex("AssigneeKind", "AssigneeId");
+
+ b.ToTable("work_items", "orgboard");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs
new file mode 100644
index 0000000..2780fc3
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
+{
+ ///
+ public partial class AddAgents : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "agents",
+ schema: "orgboard",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ SeatId = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false),
+ Monogram = table.Column(type: "character varying(8)", maxLength: 8, nullable: true),
+ Autonomy = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ ApiConfigId = table.Column(type: "uuid", nullable: false),
+ FallbackApiConfigId = table.Column(type: "uuid", nullable: true),
+ SkillKeys = table.Column>(type: "text[]", nullable: false),
+ Docs = table.Column>(type: "text[]", nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_agents", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_agents_SeatId",
+ schema: "orgboard",
+ table: "agents",
+ column: "SeatId",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "agents",
+ schema: "orgboard");
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs
index dd27b6e..66ac1b1 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs
@@ -1,5 +1,6 @@
//
using System;
+using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiConfigId")
+ .HasColumnType("uuid");
+
+ b.Property("Autonomy")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.PrimitiveCollection>("Docs")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("FallbackApiConfigId")
+ .HasColumnType("uuid");
+
+ b.Property("Monogram")
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("SeatId")
+ .HasColumnType("uuid");
+
+ b.PrimitiveCollection>("SkillKeys")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeatId")
+ .IsUnique();
+
+ b.ToTable("agents", "orgboard");
+ });
+
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{
b.Property("Id")
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs
index 298de91..3af4055 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti
public DbSet Organizations => Set();
public DbSet Teams => Set();
public DbSet Seats => Set();
+ public DbSet Agents => Set();
public DbSet WorkItems => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti
seat.HasIndex(s => s.TeamId);
});
+ modelBuilder.Entity(agent =>
+ {
+ agent.ToTable("agents");
+ agent.HasKey(a => a.Id);
+ agent.Property(a => a.Name).HasMaxLength(120).IsRequired();
+ agent.Property(a => a.Monogram).HasMaxLength(8);
+ agent.Property(a => a.Autonomy).HasConversion().HasMaxLength(20);
+ agent.HasIndex(a => a.SeatId).IsUnique();
+ });
+
modelBuilder.Entity(workItem =>
{
workItem.ToTable("work_items");
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index 32fd14a..200503c 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -12,7 +12,7 @@
- $(NoWarn);CA1707;CA1711;xUnit1051
+ $(NoWarn);CA1707;CA1711;CA1861;xUnit1051
diff --git a/tests/TeamUp.IntegrationTests/SeatConfigTests.cs b/tests/TeamUp.IntegrationTests/SeatConfigTests.cs
new file mode 100644
index 0000000..35bb467
--- /dev/null
+++ b/tests/TeamUp.IntegrationTests/SeatConfigTests.cs
@@ -0,0 +1,110 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Xunit;
+
+namespace TeamUp.IntegrationTests;
+
+///
+/// M3 acceptance: an owner adds a BYOK config, then configures an AI seat ("Aria", gated autonomy,
+/// a skill, that config) — flipping the seat to AI — without the key ever being exposed.
+///
+public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture
+{
+ private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
+
+ private sealed record OrganizationResponse(Guid Id, string Name);
+
+ private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
+
+ private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
+
+ private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
+
+ private sealed record AgentResponse(
+ Guid Id, Guid SeatId, string Name, string? Monogram, string Autonomy,
+ Guid ApiConfigId, Guid? FallbackApiConfigId, List SkillKeys, List Docs);
+
+ [Fact]
+ public async Task Owner_configures_an_ai_seat_with_skills_autonomy_and_byok_config()
+ {
+ await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
+ using var anon = factory.CreateClient();
+
+ var owner = await Bootstrap(anon);
+ using var client = Authed(factory, owner.Token);
+
+ await PostOk(client, "/api/orgboard/organizations",
+ new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
+ var team = await PostOk(client, "/api/orgboard/teams",
+ new { organizationId = owner.OrganizationId, name = "IPNOPS" });
+
+ var config = await PostOk(client, "/api/integrations/api-configs", new
+ {
+ organizationId = owner.OrganizationId,
+ name = "Vertex-Pro",
+ provider = "stub",
+ model = "gemini-pro",
+ apiKey = "sk-byok-secret",
+ });
+
+ // Create an open seat, then configure an AI agent on it.
+ var seat = await PostOk(client, "/api/orgboard/seats",
+ new { teamId = team.Id, roleName = "Product Owner" });
+ Assert.Equal("Open", seat.State);
+
+ var agent = await PostOk(client, $"/api/orgboard/seats/{seat.Id}/agent", new
+ {
+ name = "Aria",
+ monogram = "AR",
+ autonomy = "Gated",
+ apiConfigId = config.Id,
+ skillKeys = new[] { "spec-writing", "story-breakdown" },
+ docs = new[] { "product-docs" },
+ });
+ Assert.Equal("Aria", agent.Name);
+ Assert.Equal("Gated", agent.Autonomy);
+ Assert.Equal(config.Id, agent.ApiConfigId);
+ Assert.Contains("spec-writing", agent.SkillKeys);
+
+ // Reading it back returns the same configuration.
+ var fetched = await client.GetFromJsonAsync($"/api/orgboard/seats/{seat.Id}/agent");
+ Assert.Equal(agent.Id, fetched!.Id);
+
+ // The seat is now an AI seat pointing at the agent.
+ var seats = await client.GetFromJsonAsync>($"/api/orgboard/seats?teamId={team.Id}");
+ var aiSeat = seats!.Single(s => s.Id == seat.Id);
+ Assert.Equal("Ai", aiSeat.State);
+ Assert.Equal(agent.Id, aiSeat.AgentId);
+ }
+
+ private static async Task Bootstrap(HttpClient client)
+ {
+ var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
+ {
+ organizationName = "AliaSaaS",
+ ownerEmail = "owner@alia.test",
+ ownerDisplayName = "Owner",
+ ownerPassword = "Passw0rd!",
+ });
+ var owner = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(owner);
+ return owner!;
+ }
+
+ private static HttpClient Authed(TeamUpWebFactory factory, string token)
+ {
+ var client = factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return client;
+ }
+
+ private static async Task PostOk(HttpClient client, string url, object body)
+ {
+ var response = await client.PostAsJsonAsync(url, body);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var value = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(value);
+ return value!;
+ }
+}