M3: Agent bound to a seat — configure an AI seat

OrgBoard: Agent entity (name, monogram, autonomy dial, ApiConfigId + optional fallback,
skill keys, docs) + AddAgents migration; one agent per seat. References Skills by key and
the BYOK config by id — never reaches into those modules.

Endpoints: POST/GET /api/orgboard/seats (create/list seats), POST/GET
/api/orgboard/seats/{id}/agent (configure/read the agent) — ConfigureAgents at [team, org].
Configuring an agent flips the seat to the AI state and points it at the agent; audited.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 27/27 incl. the M3 acceptance
flow — owner adds a BYOK config, then configures "Aria" (gated autonomy, skills, that config)
on a seat, flipping it to AI, with the key never exposed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 23:49:28 +03:30
parent 1559975518
commit e202246a1c
10 changed files with 658 additions and 1 deletions
@@ -0,0 +1,54 @@
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
/// <summary>
/// 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.
/// </summary>
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<string> SkillKeys { get; private set; } = [];
public List<string> 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<string> skillKeys,
List<string> docs,
DateTimeOffset nowUtc)
{
Name = name;
Monogram = monogram;
Autonomy = autonomy;
ApiConfigId = apiConfigId;
FallbackApiConfigId = fallbackApiConfigId;
SkillKeys = skillKeys;
Docs = docs;
UpdatedAtUtc = nowUtc;
}
}
@@ -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;
}
}
@@ -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<TaskResponse> Items);
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> 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<string> SkillKeys,
List<string> Docs);
internal sealed record AgentResponse(
Guid Id,
Guid SeatId,
string Name,
string? Monogram,
string Autonomy,
Guid ApiConfigId,
Guid? FallbackApiConfigId,
List<string> SkillKeys,
List<string> Docs);
@@ -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<IResult> 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<IResult> 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<IResult> 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<IResult> 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));
}
}
@@ -0,0 +1,217 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("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
}
}
}
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAgents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "agents",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Monogram = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
Autonomy = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ApiConfigId = table.Column<Guid>(type: "uuid", nullable: false),
FallbackApiConfigId = table.Column<Guid>(type: "uuid", nullable: true),
SkillKeys = table.Column<List<string>>(type: "text[]", nullable: false),
Docs = table.Column<List<string>>(type: "text[]", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "agents",
schema: "orgboard");
}
}
}
@@ -1,5 +1,6 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("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<Guid>("Id")
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Team> Teams => Set<Team>();
public DbSet<Seat> Seats => Set<Seat>();
public DbSet<Agent> Agents => Set<Agent>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
seat.HasIndex(s => s.TeamId);
});
modelBuilder.Entity<Agent>(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<string>().HasMaxLength(20);
agent.HasIndex(a => a.SeatId).IsUnique();
});
modelBuilder.Entity<WorkItem>(workItem =>
{
workItem.ToTable("work_items");