diff --git a/src/ClaudeDo.Data/ClaudeDo.Data.csproj b/src/ClaudeDo.Data/ClaudeDo.Data.csproj
index 624930a..6bb4de7 100644
--- a/src/ClaudeDo.Data/ClaudeDo.Data.csproj
+++ b/src/ClaudeDo.Data/ClaudeDo.Data.csproj
@@ -7,7 +7,11 @@
-
+
+
+
+
+
diff --git a/src/ClaudeDo.Data/Models/ListEntity.cs b/src/ClaudeDo.Data/Models/ListEntity.cs
new file mode 100644
index 0000000..5c5809e
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/ListEntity.cs
@@ -0,0 +1,10 @@
+namespace ClaudeDo.Data.Models;
+
+public sealed class ListEntity
+{
+ public required string Id { get; init; }
+ public required string Name { get; set; }
+ public required DateTime CreatedAt { get; init; }
+ public string? WorkingDir { get; set; }
+ public string DefaultCommitType { get; set; } = "chore";
+}
diff --git a/src/ClaudeDo.Data/Models/TagEntity.cs b/src/ClaudeDo.Data/Models/TagEntity.cs
new file mode 100644
index 0000000..01c8684
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/TagEntity.cs
@@ -0,0 +1,7 @@
+namespace ClaudeDo.Data.Models;
+
+public sealed class TagEntity
+{
+ public long Id { get; init; }
+ public required string Name { get; set; }
+}
diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs
new file mode 100644
index 0000000..eef77b4
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/TaskEntity.cs
@@ -0,0 +1,26 @@
+namespace ClaudeDo.Data.Models;
+
+public enum TaskStatus
+{
+ Manual,
+ Queued,
+ Running,
+ Done,
+ Failed,
+}
+
+public sealed class TaskEntity
+{
+ public required string Id { get; init; }
+ public required string ListId { get; init; }
+ public required string Title { get; set; }
+ public string? Description { get; set; }
+ public TaskStatus Status { get; set; } = TaskStatus.Manual;
+ public DateTime? ScheduledFor { get; set; }
+ public string? Result { get; set; }
+ public string? LogPath { get; set; }
+ public required DateTime CreatedAt { get; init; }
+ public DateTime? StartedAt { get; set; }
+ public DateTime? FinishedAt { get; set; }
+ public string CommitType { get; set; } = "chore";
+}
diff --git a/src/ClaudeDo.Data/Models/WorktreeEntity.cs b/src/ClaudeDo.Data/Models/WorktreeEntity.cs
new file mode 100644
index 0000000..6378b78
--- /dev/null
+++ b/src/ClaudeDo.Data/Models/WorktreeEntity.cs
@@ -0,0 +1,21 @@
+namespace ClaudeDo.Data.Models;
+
+public enum WorktreeState
+{
+ Active,
+ Merged,
+ Discarded,
+ Kept,
+}
+
+public sealed class WorktreeEntity
+{
+ public required string TaskId { get; init; }
+ public required string Path { get; set; }
+ public required string BranchName { get; set; }
+ public required string BaseCommit { get; set; }
+ public string? HeadCommit { get; set; }
+ public string? DiffStat { get; set; }
+ public WorktreeState State { get; set; } = WorktreeState.Active;
+ public required DateTime CreatedAt { get; init; }
+}
diff --git a/src/ClaudeDo.Data/Paths.cs b/src/ClaudeDo.Data/Paths.cs
new file mode 100644
index 0000000..c55037f
--- /dev/null
+++ b/src/ClaudeDo.Data/Paths.cs
@@ -0,0 +1,31 @@
+namespace ClaudeDo.Data;
+
+public static class Paths
+{
+ ///
+ /// Expands a leading "~" or "%USERPROFILE%" and returns an absolute path.
+ /// Relative paths are resolved against (default: current directory).
+ ///
+ public static string Expand(string path, string? baseDir = null)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ throw new ArgumentException("Path must not be empty.", nameof(path));
+
+ var expanded = Environment.ExpandEnvironmentVariables(path);
+
+ if (expanded.StartsWith("~", StringComparison.Ordinal))
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ expanded = home + expanded[1..];
+ }
+
+ if (!Path.IsPathRooted(expanded))
+ expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
+
+ return Path.GetFullPath(expanded);
+ }
+
+ /// ~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.
+ public static string AppDataRoot() =>
+ Expand("~/.todo-app");
+}
diff --git a/src/ClaudeDo.Data/SchemaInitializer.cs b/src/ClaudeDo.Data/SchemaInitializer.cs
new file mode 100644
index 0000000..17eff67
--- /dev/null
+++ b/src/ClaudeDo.Data/SchemaInitializer.cs
@@ -0,0 +1,41 @@
+using System.Reflection;
+using Microsoft.Data.Sqlite;
+
+namespace ClaudeDo.Data;
+
+///
+/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
+/// IF NOT EXISTS / INSERT OR IGNORE.
+///
+public static class SchemaInitializer
+{
+ private const string ResourceName = "ClaudeDo.Data.schema.sql";
+
+ public static void Apply(SqliteConnectionFactory factory)
+ {
+ using var conn = factory.Open();
+ ApplyTo(conn);
+ }
+
+ public static void ApplyTo(SqliteConnection conn)
+ {
+ var sql = LoadScript();
+ using var tx = conn.BeginTransaction();
+ using var cmd = conn.CreateCommand();
+ cmd.Transaction = tx;
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ tx.Commit();
+ }
+
+ private static string LoadScript()
+ {
+ var asm = typeof(SchemaInitializer).Assembly;
+ using var stream = asm.GetManifestResourceStream(ResourceName)
+ ?? throw new InvalidOperationException(
+ $"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
+ $"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+}
diff --git a/src/ClaudeDo.Data/SqliteConnectionFactory.cs b/src/ClaudeDo.Data/SqliteConnectionFactory.cs
new file mode 100644
index 0000000..43da6fa
--- /dev/null
+++ b/src/ClaudeDo.Data/SqliteConnectionFactory.cs
@@ -0,0 +1,48 @@
+using Microsoft.Data.Sqlite;
+
+namespace ClaudeDo.Data;
+
+///
+/// Opens instances pointed at .
+/// First call ensures the parent directory exists, enables WAL and foreign keys.
+///
+public sealed class SqliteConnectionFactory
+{
+ public string DbPath { get; }
+ private readonly string _connectionString;
+ private int _walApplied;
+
+ public SqliteConnectionFactory(string dbPath)
+ {
+ DbPath = Paths.Expand(dbPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
+
+ _connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = DbPath,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ Cache = SqliteCacheMode.Shared,
+ }.ToString();
+ }
+
+ public SqliteConnection Open()
+ {
+ var conn = new SqliteConnection(_connectionString);
+ conn.Open();
+
+ // WAL is a persistent DB-level setting; applying it once per process is enough,
+ // but idempotent so we do it defensively on the first connection we hand out.
+ if (Interlocked.Exchange(ref _walApplied, 1) == 0)
+ {
+ using var pragma = conn.CreateCommand();
+ pragma.CommandText = "PRAGMA journal_mode=WAL;";
+ pragma.ExecuteNonQuery();
+ }
+
+ using var fk = conn.CreateCommand();
+ fk.CommandText = "PRAGMA foreign_keys=ON;";
+ fk.ExecuteNonQuery();
+
+ return conn;
+ }
+}
diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
index 4cf11ce..ad75153 100644
--- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
new file mode 100644
index 0000000..6ef4b0d
--- /dev/null
+++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Worker.Config;
+
+public sealed class WorkerConfig
+{
+ [JsonPropertyName("db_path")]
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+
+ [JsonPropertyName("sandbox_root")]
+ public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
+
+ [JsonPropertyName("log_root")]
+ public string LogRoot { get; set; } = "~/.todo-app/logs";
+
+ /// "sibling" → place worktrees next to the target repo; "central" → under .
+ [JsonPropertyName("worktree_root_strategy")]
+ public string WorktreeRootStrategy { get; set; } = "sibling";
+
+ [JsonPropertyName("central_worktree_root")]
+ public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
+
+ [JsonPropertyName("queue_backstop_interval_ms")]
+ public int QueueBackstopIntervalMs { get; set; } = 30_000;
+
+ [JsonPropertyName("signalr_port")]
+ public int SignalRPort { get; set; } = 47_821;
+
+ [JsonPropertyName("claude_bin")]
+ public string ClaudeBin { get; set; } = "claude";
+
+ public static string DefaultConfigPath =>
+ Path.Combine(Paths.AppDataRoot(), "worker.config.json");
+
+ ///
+ /// Loads the config from (defaults to ).
+ /// Missing file → returns defaults. Resolves all path-typed fields to absolute paths.
+ ///
+ public static WorkerConfig Load(string? path = null)
+ {
+ path ??= DefaultConfigPath;
+
+ WorkerConfig cfg;
+ if (File.Exists(path))
+ {
+ var json = File.ReadAllText(path);
+ cfg = JsonSerializer.Deserialize(json, JsonOpts)
+ ?? throw new InvalidOperationException($"Failed to parse {path}");
+ }
+ else
+ {
+ cfg = new WorkerConfig();
+ }
+
+ cfg.DbPath = Paths.Expand(cfg.DbPath);
+ cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
+ cfg.LogRoot = Paths.Expand(cfg.LogRoot);
+ cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
+
+ return cfg;
+ }
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+}
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
new file mode 100644
index 0000000..24d32ab
--- /dev/null
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -0,0 +1,16 @@
+using System.Reflection;
+using Microsoft.AspNetCore.SignalR;
+
+namespace ClaudeDo.Worker.Hub;
+
+///
+/// SignalR hub the UI connects to. Only is implemented at this stage;
+/// RunNow/CancelTask/WakeQueue/GetActive land here once QueueService exists.
+///
+public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
+{
+ private static readonly string Version =
+ Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
+
+ public string Ping() => $"pong v{Version}";
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 1760df1..55386fb 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -1,6 +1,27 @@
+using ClaudeDo.Data;
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Hub;
+
+var cfg = WorkerConfig.Load();
+
var builder = WebApplication.CreateBuilder(args);
+
+// Initialize DB schema before the host starts accepting connections.
+var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
+SchemaInitializer.Apply(dbFactory);
+
+builder.Services.AddSingleton(cfg);
+builder.Services.AddSingleton(dbFactory);
+builder.Services.AddSignalR();
+
+// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
+builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
+
var app = builder.Build();
-app.MapGet("/", () => "Hello World!");
+app.MapHub("/hub");
+
+app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
+ cfg.SignalRPort, cfg.DbPath);
app.Run();