diff --git a/README.md b/README.md
index 438e26dcbc79c6ecdb010a16448c81986a468e90..6b1c15b383abbc59f6db1bf6b1920f3174206e4d 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,32 @@
 The history does not store recovery credentials, login challenges, signatures,
 session tokens, or SSH private keys.
 
+An authenticated account can create a repository with SSH:
+
+```text
+ssh -p 2222 tit.example repo create project
+```
+
+The account that owns the SSH key becomes the repository owner. The SSH login
+name does not select the owner. New repositories use SHA-1 unless the command
+selects SHA-256:
+
+```text
+ssh -p 2222 tit.example repo create project --object-format sha256
+```
+
+Use the versioned JSON mode for scripts:
+
+```text
+ssh -p 2222 tit.example repo create project --output json
+```
+
+The complete command is `repo create NAME [--object-format sha1|sha256]
+[--output human|json]`. Human output can change in a later release. The JSON
+object has `version`, `status`, and `repository` fields after success. It has
+`version`, `status`, and `error.code` fields after failure. The command returns
+zero after success and nonzero after failure.
+
 ## Quality gate
 
 Install `cargo-deny` version 0.20.2. Then, run this command from the repository
@@ -240,3 +266,17 @@
 exclusion. Read the
 [audit history architectural decision record](docs/adr/0014-audit-history.md)
 for the transaction and recovery rules.
+
+## Milestone 3.6 gate
+
+Run the SSH repository command gate:
+
+```text
+./scripts/check-m3-6
+```
+
+This command tests repository creation from the Web UI and SSH. It tests
+account ownership, both object formats, human output, JSON output, failure
+codes, audit events, and clone access. Read the
+[SSH repository command architectural decision record](docs/adr/0015-ssh-repository-commands.md)
+for the command and authorization rules.
diff --git a/docs/adr/0015-ssh-repository-commands.md b/docs/adr/0015-ssh-repository-commands.md
new file mode 100644
index 0000000000000000000000000000000000000000..20f6038bd52f7cf8a5a3a1676e356e00623ba85f
--- /dev/null
+++ b/docs/adr/0015-ssh-repository-commands.md
@@ -1,0 +1,99 @@
+# Architectural decision record 0015: SSH repository commands
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+An account needs to create a repository without a browser. The SSH command
+must use the account that the SSH public key selects. It must not start a shell
+or parse a shell language. A script needs output that does not depend on the
+human text.
+
+Repository creation is also available in the account Web page. Both transports
+must apply the same name rules, owner rules, storage steps, and audit rules.
+
+## Decision
+
+Use one `RepositoryService` for the Web form, the SSH command, and the offline
+administrator create command. The Web and SSH methods set the owner to the
+authenticated account. The administrator method can name an owner because it
+runs only while the offline instance lock is held. The store rejects an owner
+account that does not exist or is not active.
+
+Accept this SSH command:
+
+```text
+repo create NAME [--object-format sha1|sha256] [--output human|json]
+```
+
+The default object format is SHA-1. The default output is human text. Options
+can occur in either order, but each option can occur one time. Limit the full
+command to 512 ASCII bytes. Reject control characters, unknown words, missing
+values, repeated options, invalid names, and unsupported values. Do not use a
+shell tokenizer or pass command data to a process.
+
+The human success output has two lines. The first line identifies the owner and
+repository. The second line identifies the object format. Human output is for
+a person and is not a script interface.
+
+JSON output is one UTF-8 line. A successful result has this form:
+
+```json
+{"version":1,"status":"success","repository":{"owner":"alice","name":"project","object_format":"sha1"}}
+```
+
+A failed result has this form:
+
+```json
+{"version":1,"status":"error","error":{"code":"repository-exists"}}
+```
+
+Version 1 error codes are `invalid-command`, `invalid-name`,
+`repository-exists`, `account-unavailable`, `service-unavailable`, and
+`repository-create-failed`. Send JSON results on standard output. Send human
+errors on standard error. Return exit status zero for success and one for
+failure.
+
+Create a bare repository in a random pending path under the managed repository
+root. Rename it to its random final path before the SQLite insert. If a later
+step fails, remove the path that this request created. Insert the repository,
+repository event, and successful audit event in one SQLite transaction. Record
+a failed audit event in a separate transaction after a rejected create
+operation.
+
+## Failure and threat cases
+
+The SSH username does not select the account or owner. The authenticated public
+key selects the account. A current active-account check occurs again in the
+repository transaction. This check rejects a key if its account became inactive
+after SSH authentication.
+
+Repository names use the common domain validator. They cannot contain a path
+separator, begin or end with punctuation, use a reserved route name, or end in
+`.git`. JSON strings contain only values that this validator and the account
+validator accept, so command data cannot add JSON syntax.
+
+The SSH server continues to reject shells, terminal allocation, forwarding,
+subsystems, agent forwarding, and arbitrary commands. The repository command
+uses the existing bounded worker pool. It does not run Git or OpenSSH at
+runtime.
+
+## Evidence
+
+The production server test creates repositories as a non-administrator and as
+an administrator. It compares the exact human and JSON output, checks a stable
+JSON failure, clones the new empty repository with stock Git, and verifies the
+stored owner, object format, and audit outcomes.
+
+The Web production test creates a SHA-256 repository from the account page. It
+checks the CSRF failure, redirect, public route, authenticated owner, and HTTP
+request correlation ID. The existing SSH security tests continue to reject all
+other exec commands and SSH features.
+
+## Consequences
+
+Future SSH issue and pull-request commands can use the same bounded parser and
+versioned output rules. A later JSON version can add fields without changing
+version 1. Scripts select JSON explicitly and do not parse human text.
diff --git a/scripts/check-m3-6 b/scripts/check-m3-6
new file mode 100755
index 0000000000000000000000000000000000000000..385bdfb18c017d60ab7b95ed986db153d474c82b
--- /dev/null
+++ b/scripts/check-m3-6
@@ -1,0 +1,8 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test cli administers_repositories_with_immutable_canonical_paths
+cargo test --locked --release --test ssh
+cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
+cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output
diff --git a/src/admin.rs b/src/admin.rs
index 92019bbddc84c64ec88bd60ef0689c28fc339d08..c4d2cc97f3f4b6e8a98c277ef68f4bce42e3f191 100644
--- a/src/admin.rs
+++ b/src/admin.rs
@@ -10,6 +10,7 @@
 use crate::domain::repository::{RepositoryNameError, validate_slug};
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
+use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::store::{
     AuditContext, NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store,
     StoreError,
@@ -23,17 +24,12 @@
     slug: &str,
     object_format: Kind,
 ) -> Result<RepositoryRecord, AdminError> {
-    validate_names(owner, slug)?;
-    administer_repository(
-        instance_dir,
-        owner,
-        slug,
-        RepositoryOrigin::Created,
-        |path| {
-            GitRepository::create_bare(path, object_format)?;
-            Ok(object_format)
-        },
-    )
+    let _lock = InstanceLock::acquire(instance_dir)?;
+    let database = prepare_database(instance_dir)?;
+    let root = prepare_repository_root(instance_dir)?;
+    RepositoryService::new(&database, &root)
+        .create_for_administrator(owner, slug, object_format, &random_id()?)
+        .map_err(Into::into)
 }
 
 pub(crate) fn import_repository(
@@ -409,6 +405,8 @@
     Store(#[from] StoreError),
     #[error(transparent)]
     Git(#[from] GitRepositoryError),
+    #[error(transparent)]
+    RepositoryService(#[from] RepositoryServiceError),
     #[error("cannot canonicalize path {path}: {source}")]
     Canonicalize {
         path: PathBuf,
diff --git a/src/git/transport.rs b/src/git/transport.rs
index 2346cfb07b4d12caeda68947340d12ff86e68908..76f3d3c757fa3788acac2348d15357fbc75c7caf 100644
--- a/src/git/transport.rs
+++ b/src/git/transport.rs
@@ -208,6 +208,10 @@
         self.push_database.as_deref()
     }
 
+    pub(crate) fn repository_root(&self) -> &Path {
+        &self.root
+    }
+
     pub(crate) async fn push_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
         self.push_jobs.clone().acquire_owned().await
     }
diff --git a/src/http/mod.rs b/src/http/mod.rs
index e5546004dd2dde5d904cc310b0885a36b9460dbb..a2e3d8caea9b521a50c93fd74b0813d6f6707fd1 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -20,6 +20,7 @@
 use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::session::{SessionError, WebLoginService};
 use crate::store::StoreError;
 
@@ -40,6 +41,7 @@
     jobs: Arc<Semaphore>,
     key_reloader: Option<AccountKeyReloader>,
     login: Option<WebLoginService>,
+    repositories: Option<RepositoryService>,
     secure_cookies: bool,
 }
 
@@ -71,6 +73,7 @@
                 jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
                 key_reloader: None,
                 login: None,
+                repositories: None,
                 secure_cookies: false,
             },
         )
@@ -105,6 +108,7 @@
         let secure_cookies = public_url.scheme() == "https";
         let login = WebLoginService::new(database, &public_url)?;
         let public = PublicWeb::open(config, Arc::clone(&jobs))?;
+        let repositories = RepositoryService::new(public.database(), public.repository_root());
         Self::start_with_state(
             address,
             WebState {
@@ -113,6 +117,7 @@
                 jobs,
                 key_reloader,
                 login: Some(login),
+                repositories: Some(repositories),
                 secure_cookies,
             },
         )
@@ -155,6 +160,7 @@
         jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
         key_reloader: None,
         login: None,
+        repositories: None,
         secure_cookies: false,
     })
 }
@@ -194,6 +200,10 @@
             axum::routing::post(login_verify_file).layer(DefaultBodyLimit::max(64 * 1024)),
         )
         .route("/account", get(account_page))
+        .route(
+            "/account/repositories",
+            axum::routing::post(create_repository).layer(DefaultBodyLimit::max(4 * 1024)),
+        )
         .route(
             "/logout",
             axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
@@ -603,6 +613,101 @@
     }
 }
 
+async fn create_repository(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "name", "object-format"]) {
+        Ok(fields) => fields,
+        Err(()) => {
+            return repository_form_error(
+                &request_id.0,
+                StatusCode::BAD_REQUEST,
+                "The repository request is not valid.",
+            );
+        }
+    };
+    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(false);
+    };
+    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+        return login_redirect(true);
+    };
+    if fields[0] != csrf {
+        return repository_form_error(
+            &request_id.0,
+            StatusCode::FORBIDDEN,
+            "The request is not authorized.",
+        );
+    }
+    let object_format = match fields[2].as_str() {
+        "sha1" => gix::hash::Kind::Sha1,
+        "sha256" => gix::hash::Kind::Sha256,
+        _ => {
+            return repository_form_error(
+                &request_id.0,
+                StatusCode::BAD_REQUEST,
+                "The repository request is not valid.",
+            );
+        }
+    };
+    let csrf_for_auth = csrf.clone();
+    let session = match login_job(state.clone(), move |login| {
+        login.authenticate(&session_token, Some(&csrf_for_auth))
+    })
+    .await
+    {
+        Ok(session) => session,
+        Err(_) => return login_redirect(true),
+    };
+    let owner = session.username;
+    let slug = fields[1].clone();
+    let correlation_id = request_id.0.clone();
+    let owner_for_job = owner.clone();
+    let slug_for_job = slug.clone();
+    let result = repository_job(state, move |repositories| {
+        repositories.create_for_account(
+            &owner_for_job,
+            &slug_for_job,
+            object_format,
+            &correlation_id,
+        )
+    })
+    .await;
+    match result {
+        Ok(_) => Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(header::LOCATION, format!("/{owner}/{slug}"))
+            .body(Body::empty())
+            .expect("the repository redirect is valid"),
+        Err(RepositoryServiceError::Auth(_)) | Err(RepositoryServiceError::RepositoryName(_)) => {
+            repository_form_error(
+                &request_id.0,
+                StatusCode::BAD_REQUEST,
+                "The repository name is not valid.",
+            )
+        }
+        Err(RepositoryServiceError::Store(StoreError::RepositoryExists(_, _))) => {
+            repository_form_error(
+                &request_id.0,
+                StatusCode::CONFLICT,
+                "A repository with this name already exists.",
+            )
+        }
+        Err(_) => repository_form_error(
+            &request_id.0,
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "The repository could not be created.",
+        ),
+    }
+}
+
+fn repository_form_error(request_id: &str, status: StatusCode, message: &str) -> Response {
+    render_error(status, request_id, "Repository error", message)
+}
+
 async fn logout(
     State(state): State<WebState>,
     Extension(request_id): Extension<RequestId>,
@@ -661,6 +766,30 @@
     })
     .await
     .map_err(|_| SessionError::Unavailable)?
+}
+
+async fn repository_job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce(RepositoryService) -> Result<T, RepositoryServiceError> + Send + 'static,
+) -> Result<T, RepositoryServiceError> {
+    let repositories = state.repositories.ok_or_else(|| {
+        RepositoryServiceError::Store(StoreError::Integrity(
+            "repository service is unavailable".to_owned(),
+        ))
+    })?;
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        RepositoryServiceError::Store(StoreError::Integrity(
+            "repository worker pool is unavailable".to_owned(),
+        ))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        operation(repositories)
+    })
+    .await
+    .map_err(|_| {
+        RepositoryServiceError::Store(StoreError::Integrity("repository worker failed".to_owned()))
+    })?
 }
 
 fn parse_named_form(
diff --git a/src/http/public.rs b/src/http/public.rs
index c888b44334b8b53b53eef1f7d14cd7afd7979e68..748c7c6ff631bc2bacc685639a89f8b47ce72e0b 100644
--- a/src/http/public.rs
+++ b/src/http/public.rs
@@ -1,6 +1,6 @@
 use std::fs;
 use std::io::Write;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use std::time::{Duration, UNIX_EPOCH};
 
@@ -72,6 +72,14 @@
             jobs,
             policy,
         })
+    }
+
+    pub(super) fn database(&self) -> &Path {
+        &self.database
+    }
+
+    pub(super) fn repository_root(&self) -> &Path {
+        &self.repositories
     }
 
     async fn read<T, F>(
diff --git a/src/main.rs b/src/main.rs
index c9ebea1ff23f163dd4e135403e42652daefbd86b..0891b158adb8f44f14822d9740bcb70524547c84 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,6 +18,7 @@
 mod instance;
 mod markdown;
 mod policy;
+mod repository;
 mod serve;
 mod session;
 #[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
diff --git a/src/repository.rs b/src/repository.rs
new file mode 100644
index 0000000000000000000000000000000000000000..36fd40620a73f7b9d8d334495899b36ee2b0971a
--- /dev/null
+++ b/src/repository.rs
@@ -1,0 +1,225 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use gix::hash::Kind;
+use rand::TryRng;
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::git::repository::{GitRepository, GitRepositoryError};
+use crate::store::{
+    NewAuditEvent, NewRepository, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+};
+
+#[derive(Clone)]
+pub(crate) struct RepositoryService {
+    database: PathBuf,
+    root: PathBuf,
+}
+
+impl RepositoryService {
+    pub(crate) fn new(database: &Path, root: &Path) -> Self {
+        Self {
+            database: database.to_owned(),
+            root: root.to_owned(),
+        }
+    }
+
+    pub(crate) fn create_for_account(
+        &self,
+        actor: &str,
+        slug: &str,
+        object_format: Kind,
+        correlation_id: &str,
+    ) -> Result<RepositoryRecord, RepositoryServiceError> {
+        self.create(actor, actor, slug, object_format, correlation_id)
+    }
+
+    pub(crate) fn create_for_administrator(
+        &self,
+        owner: &str,
+        slug: &str,
+        object_format: Kind,
+        correlation_id: &str,
+    ) -> Result<RepositoryRecord, RepositoryServiceError> {
+        self.create("admin-cli", owner, slug, object_format, correlation_id)
+    }
+
+    fn create(
+        &self,
+        actor: &str,
+        owner: &str,
+        slug: &str,
+        object_format: Kind,
+        correlation_id: &str,
+    ) -> Result<RepositoryRecord, RepositoryServiceError> {
+        validate_username(owner)?;
+        validate_slug(slug)?;
+        let object_format_name = object_format_name(object_format)?;
+        let created_at = timestamp()?;
+        let mut store = Store::open(&self.database)?;
+        let target = format!("{owner}/{slug}");
+        let result = self.create_inner(
+            &mut store,
+            actor,
+            owner,
+            slug,
+            object_format,
+            object_format_name,
+            correlation_id,
+            created_at,
+        );
+        if result.is_err() {
+            store.record_audit_event(&NewAuditEvent {
+                action: "repository.create",
+                actor,
+                target: &target,
+                outcome: "failure",
+                correlation_id,
+                created_at,
+            })?;
+        }
+        result
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn create_inner(
+        &self,
+        store: &mut Store,
+        actor: &str,
+        owner: &str,
+        slug: &str,
+        object_format: Kind,
+        object_format_name: &str,
+        correlation_id: &str,
+        created_at: i64,
+    ) -> Result<RepositoryRecord, RepositoryServiceError> {
+        let id = random_id()?;
+        let pending_path = self.root.join(format!(".pending-{id}.git"));
+        let final_path = self.root.join(format!("{id}.git"));
+        if pending_path.exists() || final_path.exists() {
+            return Err(RepositoryServiceError::IdentifierCollision);
+        }
+
+        if let Err(error) = GitRepository::create_bare(&pending_path, object_format) {
+            remove_created_repository(&pending_path)?;
+            return Err(error.into());
+        }
+        if let Err(source) = fs::rename(&pending_path, &final_path) {
+            remove_created_repository(&pending_path)?;
+            return Err(RepositoryServiceError::Filesystem {
+                path: final_path,
+                source,
+            });
+        }
+        let canonical_path = match fs::canonicalize(&final_path) {
+            Ok(path) => path,
+            Err(source) => {
+                remove_created_repository(&final_path)?;
+                return Err(RepositoryServiceError::Canonicalize {
+                    path: final_path,
+                    source,
+                });
+            }
+        };
+        if canonical_path.parent() != Some(self.root.as_path()) {
+            remove_created_repository(&canonical_path)?;
+            return Err(RepositoryServiceError::PathEscape(canonical_path));
+        }
+
+        if let Err(error) = store.create_repository(&NewRepository {
+            id: &id,
+            owner,
+            slug,
+            object_format: object_format_name,
+            created_at,
+            origin: RepositoryOrigin::Created,
+            initial_references: &[],
+            actor,
+            correlation_id,
+        }) {
+            remove_created_repository(&canonical_path)?;
+            return Err(error.into());
+        }
+        Ok(RepositoryRecord {
+            id,
+            owner: owner.to_owned(),
+            slug: slug.to_owned(),
+            visibility: "public".to_owned(),
+            state: "active".to_owned(),
+            object_format: object_format_name.to_owned(),
+            created_at,
+            archived_at: None,
+        })
+    }
+}
+
+fn random_id() -> Result<String, RepositoryServiceError> {
+    let mut bytes = [0_u8; 16];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| RepositoryServiceError::Random)?;
+    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
+}
+
+fn timestamp() -> Result<i64, RepositoryServiceError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| RepositoryServiceError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| RepositoryServiceError::Clock)
+}
+
+fn object_format_name(kind: Kind) -> Result<&'static str, RepositoryServiceError> {
+    match kind {
+        Kind::Sha1 => Ok("sha1"),
+        Kind::Sha256 => Ok("sha256"),
+        _ => Err(RepositoryServiceError::UnsupportedObjectFormat),
+    }
+}
+
+fn remove_created_repository(path: &Path) -> Result<(), RepositoryServiceError> {
+    match fs::remove_dir_all(path) {
+        Ok(()) => Ok(()),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(source) => Err(RepositoryServiceError::Filesystem {
+            path: path.to_owned(),
+            source,
+        }),
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum RepositoryServiceError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    RepositoryName(#[from] RepositoryNameError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error(transparent)]
+    Git(#[from] GitRepositoryError),
+    #[error("cannot canonicalize path {path}: {source}")]
+    Canonicalize {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("cannot access repository path {path}: {source}")]
+    Filesystem {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("repository path leaves the repository directory: {0}")]
+    PathEscape(PathBuf),
+    #[error("random repository ID collision")]
+    IdentifierCollision,
+    #[error("cannot create a random repository ID")]
+    Random,
+    #[error("system clock is before the Unix epoch")]
+    Clock,
+    #[error("repository object format is not supported")]
+    UnsupportedObjectFormat,
+}
diff --git a/src/ssh.rs b/src/ssh.rs
index a76bc9a81d0b07bee63bda292ba87728677deebf..2ca35dbe3884b6736b8a7c55500cdf141a960b99 100644
--- a/src/ssh.rs
+++ b/src/ssh.rs
@@ -21,10 +21,14 @@
 use crate::git::transport::{GitRepositories, GitSshService};
 use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
 use crate::policy::RepositoryOperation;
+use crate::repository::{RepositoryService, RepositoryServiceError};
 
 const VERSION_COMMAND: &[u8] = b"tit --version";
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
 const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
+const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
+const REPOSITORY_CREATE_USAGE: &str =
+    "repo create NAME [--object-format sha1|sha256] [--output human|json]";
 
 pub(crate) struct RunningSshServer {
     address: SocketAddr,
@@ -489,6 +493,25 @@
             session.exit_status_request(channel, 0)?;
             session.eof(channel)?;
             session.close(channel)?;
+        } else if is_repository_command(command) {
+            self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+            session.channel_success(channel)?;
+            let machine_requested = requests_json(command);
+            let result = match parse_repository_command(command) {
+                Ok(command) => match self.active_identity() {
+                    Some(identity) => {
+                        run_repository_command(
+                            self.repositories.clone(),
+                            identity.username.clone(),
+                            command,
+                        )
+                        .await
+                    }
+                    None => Err(RepositoryCommandError::Unavailable),
+                },
+                Err(()) => Err(RepositoryCommandError::Usage),
+            };
+            send_repository_command_result(channel, result, machine_requested, session)?;
         } else {
             let service = self.open_git_service(command).await;
             if let Some(service) = service {
@@ -711,6 +734,198 @@
     ) -> Result<bool, Self::Error> {
         self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
         Ok(false)
+    }
+}
+
+#[derive(Clone, Copy)]
+enum RepositoryCommandOutput {
+    Human,
+    Json,
+}
+
+struct RepositoryCreateCommand {
+    slug: String,
+    object_format: gix::hash::Kind,
+    output: RepositoryCommandOutput,
+}
+
+fn is_repository_command(command: &[u8]) -> bool {
+    command == b"repo" || command.starts_with(b"repo ")
+}
+
+fn requests_json(command: &[u8]) -> bool {
+    std::str::from_utf8(command).is_ok_and(|command| {
+        let tokens = command.split_ascii_whitespace().collect::<Vec<_>>();
+        tokens
+            .windows(2)
+            .any(|tokens| tokens == ["--output", "json"])
+    })
+}
+
+fn parse_repository_command(command: &[u8]) -> Result<RepositoryCreateCommand, ()> {
+    if command.len() > MAX_REPOSITORY_COMMAND_BYTES || !command.is_ascii() {
+        return Err(());
+    }
+    let command = std::str::from_utf8(command).map_err(|_| ())?;
+    if command
+        .bytes()
+        .any(|byte| byte.is_ascii_control() && byte != b' ')
+    {
+        return Err(());
+    }
+    let mut tokens = command.split_ascii_whitespace();
+    if tokens.next() != Some("repo") || tokens.next() != Some("create") {
+        return Err(());
+    }
+    let slug = tokens.next().ok_or(())?.to_owned();
+    let mut object_format = None;
+    let mut output = None;
+    while let Some(option) = tokens.next() {
+        let value = tokens.next().ok_or(())?;
+        match option {
+            "--object-format" if object_format.is_none() => {
+                object_format = Some(match value {
+                    "sha1" => gix::hash::Kind::Sha1,
+                    "sha256" => gix::hash::Kind::Sha256,
+                    _ => return Err(()),
+                });
+            }
+            "--output" if output.is_none() => {
+                output = Some(match value {
+                    "human" => RepositoryCommandOutput::Human,
+                    "json" => RepositoryCommandOutput::Json,
+                    _ => return Err(()),
+                });
+            }
+            _ => return Err(()),
+        }
+    }
+    Ok(RepositoryCreateCommand {
+        slug,
+        object_format: object_format.unwrap_or(gix::hash::Kind::Sha1),
+        output: output.unwrap_or(RepositoryCommandOutput::Human),
+    })
+}
+
+enum RepositoryCommandError {
+    Usage,
+    Unavailable,
+    Create(RepositoryServiceError),
+}
+
+async fn run_repository_command(
+    repositories: Option<Arc<GitRepositories>>,
+    actor: String,
+    command: RepositoryCreateCommand,
+) -> Result<(crate::store::RepositoryRecord, RepositoryCommandOutput), RepositoryCommandError> {
+    let repositories = repositories.ok_or(RepositoryCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(RepositoryCommandError::Unavailable)?
+        .to_owned();
+    let root = repositories.repository_root().to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| RepositoryCommandError::Unavailable)?;
+    let output = command.output;
+    let correlation_id = format!("{:032x}", rand::random::<u128>());
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        RepositoryService::new(&database, &root)
+            .create_for_account(
+                &actor,
+                &command.slug,
+                command.object_format,
+                &correlation_id,
+            )
+            .map(|repository| (repository, output))
+            .map_err(RepositoryCommandError::Create)
+    })
+    .await
+    .map_err(|_| RepositoryCommandError::Unavailable)?
+}
+
+fn send_repository_command_result(
+    channel: ChannelId,
+    result: Result<
+        (crate::store::RepositoryRecord, RepositoryCommandOutput),
+        RepositoryCommandError,
+    >,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    match result {
+        Ok((repository, RepositoryCommandOutput::Human)) => {
+            session.data(
+                channel,
+                format!(
+                    "Created repository {}/{}.\nObject format: {}\n",
+                    repository.owner, repository.slug, repository.object_format
+                )
+                .into_bytes(),
+            )?;
+            finish_git_channel(channel, 0, session)
+        }
+        Ok((repository, RepositoryCommandOutput::Json)) => {
+            session.data(
+                channel,
+                format!(
+                    "{{\"version\":1,\"status\":\"success\",\"repository\":{{\"owner\":\"{}\",\"name\":\"{}\",\"object_format\":\"{}\"}}}}\n",
+                    repository.owner, repository.slug, repository.object_format
+                )
+                .into_bytes(),
+            )?;
+            finish_git_channel(channel, 0, session)
+        }
+        Err(error) => {
+            if machine_requested {
+                session.data(
+                    channel,
+                    format!(
+                        "{{\"version\":1,\"status\":\"error\",\"error\":{{\"code\":\"{}\"}}}}\n",
+                        repository_command_error_code(&error)
+                    )
+                    .into_bytes(),
+                )?;
+            } else {
+                session.extended_data(
+                    channel,
+                    1,
+                    format!("tit: {}\n", repository_command_error_message(&error)).into_bytes(),
+                )?;
+            }
+            finish_git_channel(channel, 1, session)
+        }
+    }
+}
+
+fn repository_command_error_code(error: &RepositoryCommandError) -> &'static str {
+    match error {
+        RepositoryCommandError::Usage => "invalid-command",
+        RepositoryCommandError::Unavailable => "service-unavailable",
+        RepositoryCommandError::Create(RepositoryServiceError::Store(
+            crate::store::StoreError::RepositoryExists(_, _),
+        )) => "repository-exists",
+        RepositoryCommandError::Create(RepositoryServiceError::Auth(_))
+        | RepositoryCommandError::Create(RepositoryServiceError::RepositoryName(_)) => {
+            "invalid-name"
+        }
+        RepositoryCommandError::Create(RepositoryServiceError::Store(
+            crate::store::StoreError::AccountNotFound(_),
+        )) => "account-unavailable",
+        RepositoryCommandError::Create(_) => "repository-create-failed",
+    }
+}
+
+fn repository_command_error_message(error: &RepositoryCommandError) -> String {
+    match repository_command_error_code(error) {
+        "invalid-command" => format!("usage: {REPOSITORY_CREATE_USAGE}"),
+        "repository-exists" => "A repository with this name already exists.".to_owned(),
+        "invalid-name" => "The repository name is not valid.".to_owned(),
+        "account-unavailable" => "The account is not active.".to_owned(),
+        "service-unavailable" => "The repository service is not available.".to_owned(),
+        _ => "The repository could not be created.".to_owned(),
     }
 }
 
diff --git a/templates/account-page.html b/templates/account-page.html
index 2e96854719fee08b109692eb3e0e899d2b50880c..4a99edbfc48f006d5608fe3ac1f2b10e5928de14 100644
--- a/templates/account-page.html
+++ b/templates/account-page.html
@@ -8,6 +8,23 @@
     <dt>Administrator</dt>
     <dd>{% if administrator %}Yes{% else %}No{% endif %}</dd>
   </dl>
+  <h2>Create repository</h2>
+  <form action="/account/repositories" method="post">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <p>
+      <label for="repository-name">Name</label>
+      <input id="repository-name" name="name" required maxlength="100" pattern="([a-z0-9]|[a-z0-9][a-z0-9._-]*[a-z0-9])">
+    </p>
+    <p>
+      <label for="object-format">Object format</label>
+      <select id="object-format" name="object-format">
+        <option value="sha1">SHA-1</option>
+        <option value="sha256">SHA-256</option>
+      </select>
+    </p>
+    <button type="submit">Create repository</button>
+  </form>
+  <h2>Sessions</h2>
   <form action="/logout" method="post">
     <input type="hidden" name="csrf" value="{{ csrf }}">
     <button type="submit">Log out all sessions</button>
diff --git a/tests/git_push_ssh.rs b/tests/git_push_ssh.rs
index 11bbf8127dd7482ebe495af22254e87276e14c02..b59e5efb19e0115bb93bf6b49122c6d014956cbd 100644
--- a/tests/git_push_ssh.rs
+++ b/tests/git_push_ssh.rs
@@ -4,6 +4,9 @@
 )]
 #[path = "../src/auth.rs"]
 mod auth;
+#[allow(dead_code, reason = "the SSH push test does not use domain models")]
+#[path = "../src/domain/mod.rs"]
+mod domain;
 #[allow(
     dead_code,
     reason = "the SSH push test does not use each Git service API"
@@ -13,6 +16,9 @@
 #[allow(dead_code, reason = "the SSH push test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the SSH push test does not create repositories")]
+#[path = "../src/repository.rs"]
+mod repository;
 #[allow(
     dead_code,
     reason = "the SSH push test does not inspect the request audit"
diff --git a/tests/git_ssh.rs b/tests/git_ssh.rs
index d2aa6ca8d2041060a5bd71677c8e339d1bc1c5db..da0c2555fe22c5d23eafb0d420f606c9c4d973b9 100644
--- a/tests/git_ssh.rs
+++ b/tests/git_ssh.rs
@@ -4,6 +4,9 @@
 )]
 #[path = "../src/auth.rs"]
 mod auth;
+#[allow(dead_code, reason = "the SSH Git test does not use domain models")]
+#[path = "../src/domain/mod.rs"]
+mod domain;
 #[allow(
     dead_code,
     reason = "the SSH Git test does not use each Git service API"
@@ -13,6 +16,9 @@
 #[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the SSH Git test does not create repositories")]
+#[path = "../src/repository.rs"]
+mod repository;
 #[allow(
     dead_code,
     reason = "the SSH Git test does not inspect the request audit"
diff --git a/tests/public_routes.rs b/tests/public_routes.rs
index 6096f4832bf5ad9a484981ca1cfb11849779440e..e33ea94466da269e8365bf98864e024cd37f4d64 100644
--- a/tests/public_routes.rs
+++ b/tests/public_routes.rs
@@ -37,6 +37,12 @@
 #[allow(dead_code, reason = "the public-route test uses anonymous policy only")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(
+    dead_code,
+    reason = "the public route test does not create repositories through forms"
+)]
+#[path = "../src/repository.rs"]
+mod repository;
 #[allow(dead_code, reason = "the public-route test does not complete a login")]
 #[path = "../src/session.rs"]
 mod session;
diff --git a/tests/serve.rs b/tests/serve.rs
index 6968bb1a3aeffd1d01387fcc9697b1573408f713..ba341695de23671a0741abdd8a88e4d55e0f9e4d 100644
--- a/tests/serve.rs
+++ b/tests/serve.rs
@@ -9,7 +9,7 @@
 use std::net::{SocketAddr, TcpStream};
 use std::os::unix::fs::PermissionsExt;
 use std::path::Path;
-use std::process::{Child, Command, ExitStatus, Stdio};
+use std::process::{Child, Command, ExitStatus, Output, Stdio};
 use std::thread;
 use std::time::{Duration, Instant};
 
@@ -60,7 +60,6 @@
             source.to_str().expect("a UTF-8 source path"),
         ],
     );
-
     let mut server = spawn_server(&config);
     wait_for_listener(http, &mut server);
     wait_for_listener(ssh, &mut server);
@@ -120,7 +119,36 @@
     let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
     assert!(account.starts_with("HTTP/1.1 200"));
     assert!(account.contains("<dd>alice</dd>"));
+    assert!(account.contains("action=\"/account/repositories\""));
     let csrf = cookie_value(&cookies, "tit-csrf");
+    let rejected_repository = http_form_with_headers(
+        http,
+        "/account/repositories",
+        &[
+            ("csrf", &"0".repeat(64)),
+            ("name", "web-created"),
+            ("object-format", "sha1"),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(rejected_repository.starts_with("HTTP/1.1 403"));
+    let created_repository = http_form_with_headers(
+        http,
+        "/account/repositories",
+        &[
+            ("csrf", csrf),
+            ("name", "web-created"),
+            ("object-format", "sha256"),
+        ],
+        &[("Cookie", &cookies)],
+    );
+    assert!(created_repository.starts_with("HTTP/1.1 303"));
+    assert_eq!(
+        response_header(&created_repository, "location"),
+        "/alice/web-created"
+    );
+    let web_create_id = response_header(&created_repository, "x-request-id").to_owned();
+    assert!(http_get(http, "/alice/web-created").starts_with("HTTP/1.1 200"));
     let rejected_logout = http_form_with_headers(
         http,
         "/logout",
@@ -318,6 +346,13 @@
     assert!(audits.iter().any(|event| {
         event.0 == "account.recover" && event.3 == "success" && event.4 == recovery_id
     }));
+    assert!(audits.iter().any(|event| {
+        event.0 == "repository.create"
+            && event.1 == "alice"
+            && event.2 == "alice/web-created"
+            && event.3 == "success"
+            && event.4 == web_create_id
+    }));
     for event in &audits {
         let visible = format!(
             "{} {} {} {} {}",
@@ -450,7 +485,6 @@
             "private",
         ],
     );
-
     let mut server = spawn_server(&config);
     wait_for_listener(http, &mut server);
     wait_for_listener(ssh, &mut server);
@@ -481,6 +515,172 @@
         &instance.path().join("unknown-clone")
     ));
     server.terminate();
+}
+
+#[test]
+fn creates_owned_repositories_with_stable_ssh_command_output() {
+    let instance = TempDir::new().expect("create an instance directory");
+    let http = free_address();
+    let ssh = free_address();
+    let config = instance.path().join("config.toml");
+    fs::write(
+        &config,
+        format!(
+            "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+            ssh.port()
+        ),
+    )
+    .expect("write the server configuration");
+    let private_key = instance.path().join("administrator");
+    create_ssh_key_fixture(&private_key);
+    let public_key = fs::read_to_string(private_key.with_extension("pub"))
+        .expect("read the administrator public key");
+    let config_text = config.to_str().expect("a UTF-8 configuration path");
+    command(
+        instance.path(),
+        [
+            "--config",
+            config_text,
+            "setup",
+            "admin",
+            "alice",
+            public_key.trim(),
+        ],
+    );
+    let member_key = provision_account(instance.path(), "bob", "active", false);
+
+    let mut server = spawn_server(&config);
+    wait_for_listener(http, &mut server);
+    wait_for_listener(ssh, &mut server);
+
+    let human = ssh_exec(ssh, &member_key, &["repo", "create", "example"]);
+    assert!(human.status.success());
+    assert_eq!(
+        String::from_utf8(human.stdout).expect("read human command output"),
+        "Created repository bob/example.\nObject format: sha1\n"
+    );
+    assert!(human.stderr.is_empty());
+    assert!(ssh_clone_repository_succeeds(
+        ssh,
+        &member_key,
+        "bob",
+        "example",
+        &instance.path().join("created-clone")
+    ));
+
+    let machine = ssh_exec(
+        ssh,
+        &private_key,
+        &[
+            "repo",
+            "create",
+            "hash-agile",
+            "--object-format",
+            "sha256",
+            "--output",
+            "json",
+        ],
+    );
+    assert!(machine.status.success());
+    assert_eq!(
+        String::from_utf8(machine.stdout).expect("read machine command output"),
+        "{\"version\":1,\"status\":\"success\",\"repository\":{\"owner\":\"alice\",\"name\":\"hash-agile\",\"object_format\":\"sha256\"}}\n"
+    );
+    assert!(machine.stderr.is_empty());
+
+    let duplicate = ssh_exec(
+        ssh,
+        &member_key,
+        &["repo", "create", "example", "--output", "json"],
+    );
+    assert!(!duplicate.status.success());
+    assert_eq!(
+        String::from_utf8(duplicate.stdout).expect("read machine error output"),
+        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"repository-exists\"}}\n"
+    );
+    assert!(duplicate.stderr.is_empty());
+
+    let invalid = ssh_exec(ssh, &member_key, &["repo", "create", "../bad"]);
+    assert!(!invalid.status.success());
+    assert!(invalid.stdout.is_empty());
+    assert_eq!(
+        String::from_utf8(invalid.stderr).expect("read human error output"),
+        "tit: The repository name is not valid.\n"
+    );
+    let malformed = ssh_exec(ssh, &member_key, &["repo", "create", "--output", "json"]);
+    assert!(!malformed.status.success());
+    assert_eq!(
+        String::from_utf8(malformed.stdout).expect("read invalid command output"),
+        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"invalid-command\"}}\n"
+    );
+    assert!(malformed.stderr.is_empty());
+
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the account database");
+    database
+        .execute(
+            "UPDATE account SET state = 'suspended' WHERE username = 'bob'",
+            [],
+        )
+        .expect("suspend the command account");
+    drop(database);
+    let suspended = ssh_exec(
+        ssh,
+        &member_key,
+        &["repo", "create", "blocked", "--output", "json"],
+    );
+    assert!(!suspended.status.success());
+    assert_eq!(
+        String::from_utf8(suspended.stdout).expect("read suspended account output"),
+        "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
+    );
+    assert!(suspended.stderr.is_empty());
+    server.terminate();
+
+    let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the repository database");
+    let repositories: Vec<(String, String, String)> = database
+        .prepare(
+            "SELECT account.username, repository.slug, repository.object_format
+             FROM repository JOIN account ON account.id = repository.owner_account_id
+             ORDER BY repository.slug",
+        )
+        .expect("prepare the repository query")
+        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
+        .expect("query created repositories")
+        .collect::<Result<_, _>>()
+        .expect("read created repositories");
+    assert_eq!(
+        repositories,
+        vec![
+            ("bob".to_owned(), "example".to_owned(), "sha1".to_owned()),
+            (
+                "alice".to_owned(),
+                "hash-agile".to_owned(),
+                "sha256".to_owned()
+            ),
+        ]
+    );
+    let audit: Vec<(String, String)> = database
+        .prepare(
+            "SELECT target, outcome FROM audit_event
+             WHERE action = 'repository.create' AND actor IN ('alice', 'bob')
+             ORDER BY id",
+        )
+        .expect("prepare the repository audit query")
+        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
+        .expect("query repository audit events")
+        .collect::<Result<_, _>>()
+        .expect("read repository audit events");
+    assert_eq!(
+        audit,
+        vec![
+            ("bob/example".to_owned(), "success".to_owned()),
+            ("alice/hash-agile".to_owned(), "success".to_owned()),
+            ("bob/example".to_owned(), "failure".to_owned()),
+            ("bob/blocked".to_owned(), "failure".to_owned()),
+        ]
+    );
 }
 
 #[test]
@@ -986,6 +1186,31 @@
         .expect("clone through the tit SSH server")
         .status
         .success()
+}
+
+fn ssh_exec(address: SocketAddr, private_key: &Path, command: &[&str]) -> Output {
+    Command::new("ssh")
+        .args([
+            "-F",
+            "/dev/null",
+            "-o",
+            "BatchMode=yes",
+            "-o",
+            "IdentitiesOnly=yes",
+            "-o",
+            "StrictHostKeyChecking=no",
+            "-o",
+            "UserKnownHostsFile=/dev/null",
+            "-o",
+            "LogLevel=ERROR",
+            "-i",
+        ])
+        .arg(private_key)
+        .args(["-p", &address.port().to_string()])
+        .arg(format!("ignored@{}", address.ip()))
+        .args(command)
+        .output()
+        .expect("run an SSH repository command")
 }
 
 fn provision_account(
diff --git a/tests/ssh.rs b/tests/ssh.rs
index 104abe95a5f7a5c1f7307b63ff911da909fe57fd..8ef49d75e78d8c2e8b2b3adadbb2d71a62368e38 100644
--- a/tests/ssh.rs
+++ b/tests/ssh.rs
@@ -1,6 +1,9 @@
 #[allow(dead_code, reason = "the SSH test uses only the shared key boundary")]
 #[path = "../src/auth.rs"]
 mod auth;
+#[allow(dead_code, reason = "the SSH identity test does not use domain models")]
+#[path = "../src/domain/mod.rs"]
+mod domain;
 #[allow(
     dead_code,
     reason = "the SSH identity test does not use each Git service API"
@@ -13,6 +16,12 @@
 )]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(
+    dead_code,
+    reason = "the SSH identity test does not create repositories"
+)]
+#[path = "../src/repository.rs"]
+mod repository;
 #[allow(
     dead_code,
     reason = "the SSH identity test does not start a Git service"
diff --git a/tests/web_shell.rs b/tests/web_shell.rs
index 35e4a9a350f2da3024bdab80c085fe0e4f0d44be..3db8b42848b7ad686d6996e59965e377c000f264 100644
--- a/tests/web_shell.rs
+++ b/tests/web_shell.rs
@@ -35,6 +35,9 @@
 #[allow(dead_code, reason = "the Web shell test has no repository catalog")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[allow(dead_code, reason = "the Web shell test does not create repositories")]
+#[path = "../src/repository.rs"]
+mod repository;
 #[allow(dead_code, reason = "the Web shell test does not complete a login")]
 #[path = "../src/session.rs"]
 mod session;
