diff --git a/README.md b/README.md
index 20083a3371821f9ca2a1e7e608cf40b6d2d41d04..1e9f5f1ca5c975d714fe6cd397870b9324ce7ac4 100644
--- a/README.md
+++ b/README.md
@@ -362,3 +362,17 @@
 query validation, and the representative index workload. Read the
 [bounded metadata search architectural decision record](docs/adr/0020-bounded-metadata-search.md)
 for the limits, authorization, and index decision.
+
+## Milestone 4.6 gate
+
+Install stock OpenSSH. Then, run the SSH issue command gate:
+
+```text
+./scripts/check-m4-6
+```
+
+This command tests issue creation and listing with human and JSON output. It
+tests owner and reader permission, hidden private repositories, suspended
+account access, invalid input, raw Markdown storage, and atomic issue events.
+Read the [SSH issue command architectural decision record](docs/adr/0021-ssh-issue-commands.md)
+for the input, output, authorization, and error contracts.
diff --git a/docs/adr/0021-ssh-issue-commands.md b/docs/adr/0021-ssh-issue-commands.md
new file mode 100644
index 0000000000000000000000000000000000000000..9625696ed942af490fd847b8f30a60da0d7e0772
--- /dev/null
+++ b/docs/adr/0021-ssh-issue-commands.md
@@ -1,0 +1,109 @@
+# Architectural decision record 0021: SSH issue commands
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+An account needs to list and create issues without a browser. The SSH public
+key must select the account. The SSH commands must use the same issue service
+and authorization rules as the Web UI. A script also needs output that does not
+depend on human text.
+
+## Decision
+
+Accept these SSH commands:
+
+```text
+issue list OWNER/REPOSITORY [--output human|json]
+issue create OWNER/REPOSITORY [--output human|json]
+```
+
+Limit each command to 512 ASCII bytes. Reject control characters, unknown
+words, missing values, repeated options, invalid targets, and unsupported
+output values. Do not use a shell tokenizer or pass command data to a process.
+
+The create command reads UTF-8 content from standard input. The first line is
+the issue title. All content after the first newline is the plain-text Markdown
+body. A title can have a maximum of 200 bytes. A body can have a maximum of 256
+KiB. Limit the complete input before UTF-8 parsing. The body can be empty.
+
+The list command returns a maximum of 1,000 issues in decreasing issue-number
+order. Human output has one issue on each line:
+
+```text
+#12 open Correct the backup check
+```
+
+The create command returns this human output:
+
+```text
+Created issue alice/project#12.
+```
+
+JSON output is one UTF-8 line. A list result has this form:
+
+```json
+{"version":1,"status":"success","repository":{"owner":"alice","name":"project"},"issues":[{"number":12,"title":"Correct the backup check","state":"open","author":"alice","created_at":1784800000,"updated_at":1784800000}]}
+```
+
+A create result has this form:
+
+```json
+{"version":1,"status":"success","repository":{"owner":"alice","name":"project"},"issue":{"number":12,"title":"Correct the backup check","state":"open","author":"alice","created_at":1784800000,"updated_at":1784800000}}
+```
+
+A failed result has this form:
+
+```json
+{"version":1,"status":"error","error":{"code":"permission-denied"}}
+```
+
+Version 1 error codes are `invalid-command`, `invalid-input`, `invalid-target`,
+`repository-unavailable`, `permission-denied`, `account-unavailable`,
+`service-unavailable`, and `issue-command-failed`. Send JSON results on
+standard output. Send human errors on standard error. Return exit status zero
+for success and one for failure.
+
+Call `IssueService::list` and `IssueService::create` from a bounded blocking
+job. The service applies the common validation and repository authorization.
+Issue creation stores the issue and its `issue-created` event in one SQLite
+transaction.
+
+## Failure and threat cases
+
+The SSH username does not select the account. The authenticated public key
+selects the account. Check the current key identity before the create operation
+starts. The store also checks the current account state and repository role.
+
+Return `repository-unavailable` for a missing repository and for a repository
+that the account cannot read. Thus, the command does not disclose a private
+repository. A reader can list and create issues because the Web UI gives the
+same permission to this role. A suspended account cannot use its repository
+role or create an issue.
+
+Read create input only for a valid create command. Stop and close the channel
+when the input exceeds its limit. Do not interpret title or body content as a
+shell language. Serialize JSON with `serde_json` so user content cannot change
+the output structure.
+
+## Evidence
+
+The production server test uses stock OpenSSH. It creates an issue as an owner,
+lists it in human and JSON modes, and creates an issue as a reader. It proves
+that an unrelated account cannot list a private repository and that a suspended
+account cannot list it. It also covers invalid create input and the versioned
+error output.
+
+The database assertions prove that both commands use the canonical issue
+records. They prove exact Markdown preservation, the authenticated authors,
+monotonic issue numbers, and one `issue-created` event for each successful
+create operation.
+
+## Consequences
+
+The SSH interface has useful issue operations without a shell or a second
+authorization implementation. A client must send the title and body on
+standard input. A later command version can add fields without changing
+version 1.
diff --git a/scripts/check-m4-6 b/scripts/check-m4-6
new file mode 100755
index 0000000000000000000000000000000000000000..395179287d4730033d2b957852c14afe4abdb579
--- /dev/null
+++ b/scripts/check-m4-6
@@ -1,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output
diff --git a/src/issue.rs b/src/issue.rs
index 761ea5ff0bdf1bf6bc3a35d134e1a1b11721c32f..fb9d63952072fbb230b241b776b22b13bc6a4557 100644
--- a/src/issue.rs
+++ b/src/issue.rs
@@ -9,8 +9,8 @@
     IssueChange, IssueDetail, IssueRecord, NewIssue, RepositoryRecord, Store, StoreError,
 };
 
-const MAX_TITLE_BYTES: usize = 200;
-const MAX_BODY_BYTES: usize = 256 * 1024;
+pub(crate) const MAX_TITLE_BYTES: usize = 200;
+pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
 const MAX_LABEL_BYTES: usize = 80;
 
 #[derive(Clone)]
diff --git a/src/ssh.rs b/src/ssh.rs
index 2ca35dbe3884b6736b8a7c55500cdf141a960b99..c616c70b4e543c1cfad213ad8b3c50759991dc50 100644
--- a/src/ssh.rs
+++ b/src/ssh.rs
@@ -20,6 +20,7 @@
 use crate::git::receive_pack::{ReceivePack, ReceivePackError};
 use crate::git::transport::{GitRepositories, GitSshService};
 use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
+use crate::issue::{IssueError, IssueService, MAX_BODY_BYTES, MAX_TITLE_BYTES};
 use crate::policy::RepositoryOperation;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 
@@ -27,8 +28,12 @@
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
 const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
 const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
+const MAX_ISSUE_COMMAND_BYTES: usize = 512;
+const MAX_ISSUE_INPUT_BYTES: usize = MAX_TITLE_BYTES + 1 + MAX_BODY_BYTES;
 const REPOSITORY_CREATE_USAGE: &str =
     "repo create NAME [--object-format sha1|sha256] [--output human|json]";
+const ISSUE_LIST_USAGE: &str = "issue list OWNER/REPOSITORY [--output human|json]";
+const ISSUE_CREATE_USAGE: &str = "issue create OWNER/REPOSITORY [--output human|json]";
 
 pub(crate) struct RunningSshServer {
     address: SocketAddr,
@@ -297,7 +302,7 @@
             audit: Arc::clone(&self.audit),
             repositories: self.repositories.clone(),
             protocol: ProtocolVersion::V0,
-            git_channels: HashMap::new(),
+            exec_channels: HashMap::new(),
             authenticated_identity: None,
             authenticated_key: None,
             authenticated_writer: false,
@@ -311,15 +316,24 @@
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
     protocol: ProtocolVersion,
-    git_channels: HashMap<ChannelId, GitChannel>,
+    exec_channels: HashMap<ChannelId, ExecChannel>,
     authenticated_identity: Option<SshIdentity>,
     authenticated_key: Option<PublicKey>,
     authenticated_writer: bool,
 }
 
-enum GitChannel {
+enum ExecChannel {
     Upload(Box<UploadChannel>),
     Receive(Box<ReceiveChannel>),
+    IssueCreate(IssueCreateChannel),
+}
+
+struct IssueCreateChannel {
+    actor: String,
+    owner: String,
+    repository: String,
+    output: CommandOutput,
+    input: Vec<u8>,
 }
 
 struct UploadChannel {
@@ -512,6 +526,41 @@
                 Err(()) => Err(RepositoryCommandError::Usage),
             };
             send_repository_command_result(channel, result, machine_requested, session)?;
+        } else if is_issue_command(command) {
+            self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+            session.channel_success(channel)?;
+            let machine_requested = requests_json(command);
+            match (parse_issue_command(command), self.active_identity()) {
+                (Ok(IssueCommand::List(command)), Some(identity)) => {
+                    let result =
+                        run_issue_list(self.repositories.clone(), identity.username, command).await;
+                    send_issue_list_result(channel, result, machine_requested, session)?;
+                }
+                (Ok(IssueCommand::Create(command)), Some(identity)) => {
+                    self.exec_channels.insert(
+                        channel,
+                        ExecChannel::IssueCreate(IssueCreateChannel {
+                            actor: identity.username,
+                            owner: command.owner,
+                            repository: command.repository,
+                            output: command.output,
+                            input: Vec::new(),
+                        }),
+                    );
+                }
+                (Ok(_), None) => send_issue_error(
+                    channel,
+                    IssueCommandError::Unavailable,
+                    machine_requested,
+                    session,
+                )?,
+                (Err(()), _) => send_issue_error(
+                    channel,
+                    IssueCommandError::Usage,
+                    machine_requested,
+                    session,
+                )?,
+            }
         } else {
             let service = self.open_git_service(command).await;
             if let Some(service) = service {
@@ -523,9 +572,9 @@
                         advertisement,
                     } => {
                         session.data(channel, advertisement)?;
-                        self.git_channels.insert(
+                        self.exec_channels.insert(
                             channel,
-                            GitChannel::Upload(Box::new(UploadChannel {
+                            ExecChannel::Upload(Box::new(UploadChannel {
                                 service: *service,
                                 request: Vec::new(),
                             })),
@@ -544,9 +593,9 @@
                         let pack = tokio::fs::File::create(service.incoming_pack()).await;
                         match pack {
                             Ok(pack) => {
-                                self.git_channels.insert(
+                                self.exec_channels.insert(
                                     channel,
-                                    GitChannel::Receive(Box::new(ReceiveChannel {
+                                    ExecChannel::Receive(Box::new(ReceiveChannel {
                                         service: *service,
                                         owner,
                                         repository,
@@ -579,12 +628,26 @@
         data: &[u8],
         session: &mut Session,
     ) -> Result<(), Self::Error> {
-        let Some(git) = self.git_channels.remove(&channel) else {
+        let Some(exec) = self.exec_channels.remove(&channel) else {
             return Ok(());
         };
-        let mut git = match git {
-            GitChannel::Upload(git) => git,
-            GitChannel::Receive(mut git) => {
+        let mut git = match exec {
+            ExecChannel::IssueCreate(mut issue) => {
+                if append_issue_input(&mut issue.input, data).is_err() {
+                    send_issue_error(
+                        channel,
+                        IssueCommandError::Input,
+                        issue.output == CommandOutput::Json,
+                        session,
+                    )?;
+                } else {
+                    self.exec_channels
+                        .insert(channel, ExecChannel::IssueCreate(issue));
+                }
+                return Ok(());
+            }
+            ExecChannel::Upload(git) => git,
+            ExecChannel::Receive(mut git) => {
                 if receive_data(&mut git, data).await.is_err() {
                     fail_git_channel(channel, session)?;
                 } else if git.commands_complete
@@ -596,7 +659,8 @@
                         session,
                     )?;
                 } else {
-                    self.git_channels.insert(channel, GitChannel::Receive(git));
+                    self.exec_channels
+                        .insert(channel, ExecChannel::Receive(git));
                 }
                 return Ok(());
             }
@@ -611,7 +675,7 @@
             Ok(packets) => packets,
             Err(super::git::packetline::PacketLineError::TruncatedHeader)
             | Err(super::git::packetline::PacketLineError::TruncatedPacket) => {
-                self.git_channels.insert(channel, GitChannel::Upload(git));
+                self.exec_channels.insert(channel, ExecChannel::Upload(git));
                 return Ok(());
             }
             Err(_) => {
@@ -638,12 +702,12 @@
                         Some((_, Err(_))) | None => fail_git_channel(channel, session)?,
                     }
                 } else {
-                    self.git_channels.insert(channel, GitChannel::Upload(git));
+                    self.exec_channels.insert(channel, ExecChannel::Upload(git));
                 }
             }
             ProtocolVersion::V2 => {
                 if packets.last() != Some(&Packet::Flush) {
-                    self.git_channels.insert(channel, GitChannel::Upload(git));
+                    self.exec_channels.insert(channel, ExecChannel::Upload(git));
                     return Ok(());
                 }
                 let fetch = packets.iter().any(
@@ -659,7 +723,7 @@
                             finish_git_channel(channel, 0, session)?;
                         } else {
                             git.request.clear();
-                            self.git_channels.insert(channel, GitChannel::Upload(git));
+                            self.exec_channels.insert(channel, ExecChannel::Upload(git));
                         }
                     }
                     Some((_, Err(_))) | None => fail_git_channel(channel, session)?,
@@ -674,7 +738,7 @@
         channel: ChannelId,
         _session: &mut Session,
     ) -> Result<(), Self::Error> {
-        self.git_channels.remove(&channel);
+        self.exec_channels.remove(&channel);
         Ok(())
     }
 
@@ -683,15 +747,29 @@
         channel: ChannelId,
         session: &mut Session,
     ) -> Result<(), Self::Error> {
-        let Some(GitChannel::Receive(git)) = self.git_channels.remove(&channel) else {
-            return Ok(());
-        };
-        if !git.commands_complete {
-            fail_git_channel(channel, session)?;
-            return Ok(());
+        match self.exec_channels.remove(&channel) {
+            Some(ExecChannel::Receive(git)) => {
+                if !git.commands_complete {
+                    fail_git_channel(channel, session)?;
+                    return Ok(());
+                }
+                let result = finish_receive(self.repositories.clone(), git).await;
+                send_receive_result(channel, result, session)?;
+            }
+            Some(ExecChannel::IssueCreate(issue)) => {
+                let active = self
+                    .active_identity()
+                    .is_some_and(|identity| identity.username == issue.actor);
+                let machine_requested = issue.output == CommandOutput::Json;
+                let result = if active {
+                    run_issue_create(self.repositories.clone(), issue).await
+                } else {
+                    Err(IssueCommandError::Unavailable)
+                };
+                send_issue_create_result(channel, result, machine_requested, session)?;
+            }
+            Some(ExecChannel::Upload(_)) | None => {}
         }
-        let result = finish_receive(self.repositories.clone(), git).await;
-        send_receive_result(channel, result, session)?;
         Ok(())
     }
 
@@ -737,8 +815,8 @@
     }
 }
 
-#[derive(Clone, Copy)]
-enum RepositoryCommandOutput {
+#[derive(Clone, Copy, Eq, PartialEq)]
+enum CommandOutput {
     Human,
     Json,
 }
@@ -746,7 +824,7 @@
 struct RepositoryCreateCommand {
     slug: String,
     object_format: gix::hash::Kind,
-    output: RepositoryCommandOutput,
+    output: CommandOutput,
 }
 
 fn is_repository_command(command: &[u8]) -> bool {
@@ -792,8 +870,8 @@
             }
             "--output" if output.is_none() => {
                 output = Some(match value {
-                    "human" => RepositoryCommandOutput::Human,
-                    "json" => RepositoryCommandOutput::Json,
+                    "human" => CommandOutput::Human,
+                    "json" => CommandOutput::Json,
                     _ => return Err(()),
                 });
             }
@@ -803,7 +881,7 @@
     Ok(RepositoryCreateCommand {
         slug,
         object_format: object_format.unwrap_or(gix::hash::Kind::Sha1),
-        output: output.unwrap_or(RepositoryCommandOutput::Human),
+        output: output.unwrap_or(CommandOutput::Human),
     })
 }
 
@@ -817,7 +895,7 @@
     repositories: Option<Arc<GitRepositories>>,
     actor: String,
     command: RepositoryCreateCommand,
-) -> Result<(crate::store::RepositoryRecord, RepositoryCommandOutput), RepositoryCommandError> {
+) -> Result<(crate::store::RepositoryRecord, CommandOutput), RepositoryCommandError> {
     let repositories = repositories.ok_or(RepositoryCommandError::Unavailable)?;
     let database = repositories
         .push_database()
@@ -848,15 +926,12 @@
 
 fn send_repository_command_result(
     channel: ChannelId,
-    result: Result<
-        (crate::store::RepositoryRecord, RepositoryCommandOutput),
-        RepositoryCommandError,
-    >,
+    result: Result<(crate::store::RepositoryRecord, CommandOutput), RepositoryCommandError>,
     machine_requested: bool,
     session: &mut Session,
 ) -> Result<(), russh::Error> {
     match result {
-        Ok((repository, RepositoryCommandOutput::Human)) => {
+        Ok((repository, CommandOutput::Human)) => {
             session.data(
                 channel,
                 format!(
@@ -867,7 +942,7 @@
             )?;
             finish_git_channel(channel, 0, session)
         }
-        Ok((repository, RepositoryCommandOutput::Json)) => {
+        Ok((repository, CommandOutput::Json)) => {
             session.data(
                 channel,
                 format!(
@@ -926,6 +1001,358 @@
         "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(),
+    }
+}
+
+enum IssueCommand {
+    List(IssueListCommand),
+    Create(IssueCreateCommand),
+}
+
+struct IssueListCommand {
+    owner: String,
+    repository: String,
+    output: CommandOutput,
+}
+
+struct IssueCreateCommand {
+    owner: String,
+    repository: String,
+    output: CommandOutput,
+}
+
+struct IssueListResult {
+    repository: crate::store::RepositoryRecord,
+    issues: Vec<crate::store::IssueRecord>,
+    output: CommandOutput,
+}
+
+struct IssueCreateResult {
+    owner: String,
+    repository: String,
+    issue: crate::store::IssueRecord,
+    output: CommandOutput,
+}
+
+enum IssueCommandError {
+    Usage,
+    Input,
+    Unavailable,
+    Service(IssueError),
+}
+
+fn is_issue_command(command: &[u8]) -> bool {
+    command == b"issue" || command.starts_with(b"issue ")
+}
+
+fn parse_issue_command(command: &[u8]) -> Result<IssueCommand, ()> {
+    if command.len() > MAX_ISSUE_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("issue") {
+        return Err(());
+    }
+    let operation = tokens.next().ok_or(())?;
+    let target = tokens.next().ok_or(())?;
+    let (owner, repository) = target.split_once('/').ok_or(())?;
+    if owner.is_empty() || repository.is_empty() || repository.contains('/') {
+        return Err(());
+    }
+    let output = parse_output_options(tokens)?;
+    match operation {
+        "list" => Ok(IssueCommand::List(IssueListCommand {
+            owner: owner.to_owned(),
+            repository: repository.to_owned(),
+            output,
+        })),
+        "create" => Ok(IssueCommand::Create(IssueCreateCommand {
+            owner: owner.to_owned(),
+            repository: repository.to_owned(),
+            output,
+        })),
+        _ => Err(()),
+    }
+}
+
+fn parse_output_options<'a>(
+    mut tokens: impl Iterator<Item = &'a str>,
+) -> Result<CommandOutput, ()> {
+    let mut output = None;
+    while let Some(option) = tokens.next() {
+        let value = tokens.next().ok_or(())?;
+        if option != "--output" || output.is_some() {
+            return Err(());
+        }
+        output = Some(match value {
+            "human" => CommandOutput::Human,
+            "json" => CommandOutput::Json,
+            _ => return Err(()),
+        });
+    }
+    Ok(output.unwrap_or(CommandOutput::Human))
+}
+
+async fn run_issue_list(
+    repositories: Option<Arc<GitRepositories>>,
+    actor: String,
+    command: IssueListCommand,
+) -> Result<IssueListResult, IssueCommandError> {
+    let repositories = repositories.ok_or(IssueCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(IssueCommandError::Unavailable)?
+        .to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| IssueCommandError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        IssueService::new(&database)
+            .list(&command.owner, &command.repository, Some(&actor))
+            .map(|(repository, issues)| IssueListResult {
+                repository,
+                issues,
+                output: command.output,
+            })
+            .map_err(IssueCommandError::Service)
+    })
+    .await
+    .map_err(|_| IssueCommandError::Unavailable)?
+}
+
+async fn run_issue_create(
+    repositories: Option<Arc<GitRepositories>>,
+    command: IssueCreateChannel,
+) -> Result<IssueCreateResult, IssueCommandError> {
+    let (title, body) = parse_issue_input(&command.input)?;
+    let repositories = repositories.ok_or(IssueCommandError::Unavailable)?;
+    let database = repositories
+        .push_database()
+        .ok_or(IssueCommandError::Unavailable)?
+        .to_owned();
+    let permit = repositories
+        .blocking_permit()
+        .await
+        .map_err(|_| IssueCommandError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        IssueService::new(&database)
+            .create(
+                &command.owner,
+                &command.repository,
+                &command.actor,
+                &title,
+                &body,
+            )
+            .map(|issue| IssueCreateResult {
+                owner: command.owner,
+                repository: command.repository,
+                issue,
+                output: command.output,
+            })
+            .map_err(IssueCommandError::Service)
+    })
+    .await
+    .map_err(|_| IssueCommandError::Unavailable)?
+}
+
+fn parse_issue_input(input: &[u8]) -> Result<(String, String), IssueCommandError> {
+    let (title, body) = input
+        .iter()
+        .position(|byte| *byte == b'\n')
+        .map_or((input, &[][..]), |end| (&input[..end], &input[end + 1..]));
+    let title = title.strip_suffix(b"\r").unwrap_or(title);
+    Ok((
+        std::str::from_utf8(title)
+            .map_err(|_| IssueCommandError::Input)?
+            .to_owned(),
+        std::str::from_utf8(body)
+            .map_err(|_| IssueCommandError::Input)?
+            .to_owned(),
+    ))
+}
+
+fn append_issue_input(input: &mut Vec<u8>, data: &[u8]) -> Result<(), ()> {
+    if input.len().saturating_add(data.len()) > MAX_ISSUE_INPUT_BYTES {
+        return Err(());
+    }
+    input.extend_from_slice(data);
+    Ok(())
+}
+
+fn send_issue_list_result(
+    channel: ChannelId,
+    result: Result<IssueListResult, IssueCommandError>,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    match result {
+        Ok(result) => {
+            let data = match result.output {
+                CommandOutput::Human => issue_list_human(&result),
+                CommandOutput::Json => issue_list_json(&result),
+            };
+            session.data(channel, data)?;
+            finish_git_channel(channel, 0, session)
+        }
+        Err(error) => send_issue_error(channel, error, machine_requested, session),
+    }
+}
+
+fn issue_list_human(result: &IssueListResult) -> Vec<u8> {
+    if result.issues.is_empty() {
+        return b"No issues.\n".to_vec();
+    }
+    let mut output = String::new();
+    for issue in &result.issues {
+        output.push_str(&format!(
+            "#{} {} {}\n",
+            issue.number, issue.state, issue.title
+        ));
+    }
+    output.into_bytes()
+}
+
+fn issue_list_json(result: &IssueListResult) -> Vec<u8> {
+    let issues = result
+        .issues
+        .iter()
+        .map(|issue| {
+            serde_json::json!({
+                "number": issue.number,
+                "title": issue.title,
+                "state": issue.state,
+                "author": issue.author,
+                "created_at": issue.created_at,
+                "updated_at": issue.updated_at,
+            })
+        })
+        .collect::<Vec<_>>();
+    json_line(serde_json::json!({
+        "version": 1,
+        "status": "success",
+        "repository": {
+            "owner": result.repository.owner,
+            "name": result.repository.slug,
+        },
+        "issues": issues,
+    }))
+}
+
+fn send_issue_create_result(
+    channel: ChannelId,
+    result: Result<IssueCreateResult, IssueCommandError>,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    match result {
+        Ok(result) => {
+            let data = match result.output {
+                CommandOutput::Human => format!(
+                    "Created issue {}/{}#{}.\n",
+                    result.owner, result.repository, result.issue.number
+                )
+                .into_bytes(),
+                CommandOutput::Json => json_line(serde_json::json!({
+                    "version": 1,
+                    "status": "success",
+                    "repository": {
+                        "owner": result.owner,
+                        "name": result.repository,
+                    },
+                    "issue": {
+                        "number": result.issue.number,
+                        "title": result.issue.title,
+                        "state": result.issue.state,
+                        "author": result.issue.author,
+                        "created_at": result.issue.created_at,
+                        "updated_at": result.issue.updated_at,
+                    },
+                })),
+            };
+            session.data(channel, data)?;
+            finish_git_channel(channel, 0, session)
+        }
+        Err(error) => send_issue_error(channel, error, machine_requested, session),
+    }
+}
+
+fn send_issue_error(
+    channel: ChannelId,
+    error: IssueCommandError,
+    machine_requested: bool,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    if machine_requested {
+        session.data(
+            channel,
+            json_line(serde_json::json!({
+                "version": 1,
+                "status": "error",
+                "error": { "code": issue_command_error_code(&error) },
+            })),
+        )?;
+    } else {
+        session.extended_data(
+            channel,
+            1,
+            format!("tit: {}\n", issue_command_error_message(&error)).into_bytes(),
+        )?;
+    }
+    finish_git_channel(channel, 1, session)
+}
+
+fn json_line(value: serde_json::Value) -> Vec<u8> {
+    let mut output = serde_json::to_vec(&value).expect("a JSON value can be serialized");
+    output.push(b'\n');
+    output
+}
+
+fn issue_command_error_code(error: &IssueCommandError) -> &'static str {
+    match error {
+        IssueCommandError::Usage => "invalid-command",
+        IssueCommandError::Input
+        | IssueCommandError::Service(IssueError::Title | IssueError::Body) => "invalid-input",
+        IssueCommandError::Service(IssueError::Auth(_) | IssueError::RepositoryName(_)) => {
+            "invalid-target"
+        }
+        IssueCommandError::Service(IssueError::Store(
+            crate::store::StoreError::RepositoryNotFound(_, _)
+            | crate::store::StoreError::IssueHidden,
+        )) => "repository-unavailable",
+        IssueCommandError::Service(IssueError::Store(crate::store::StoreError::IssueDenied)) => {
+            "permission-denied"
+        }
+        IssueCommandError::Service(IssueError::Store(
+            crate::store::StoreError::AccountNotFound(_),
+        )) => "account-unavailable",
+        IssueCommandError::Unavailable => "service-unavailable",
+        IssueCommandError::Service(_) => "issue-command-failed",
+    }
+}
+
+fn issue_command_error_message(error: &IssueCommandError) -> String {
+    match issue_command_error_code(error) {
+        "invalid-command" => format!("usage: {ISSUE_LIST_USAGE} or {ISSUE_CREATE_USAGE}"),
+        "invalid-input" => {
+            "The first input line must be a valid title. The remaining input is the body."
+                .to_owned()
+        }
+        "invalid-target" => "The repository name is not valid.".to_owned(),
+        "repository-unavailable" => "The repository is not available.".to_owned(),
+        "permission-denied" => "The account cannot create an issue in this repository.".to_owned(),
+        "account-unavailable" => "The account is not active.".to_owned(),
+        "service-unavailable" => "The issue service is not available.".to_owned(),
+        _ => "The issue command could not be completed.".to_owned(),
     }
 }
 
@@ -1214,4 +1641,37 @@
     pub(crate) rejected_pty: usize,
     pub(crate) rejected_agent: usize,
     pub(crate) rejected_forward: usize,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_only_bounded_issue_commands_and_input() {
+        assert!(matches!(
+            parse_issue_command(b"issue list alice/project --output json"),
+            Ok(IssueCommand::List(_))
+        ));
+        assert!(matches!(
+            parse_issue_command(b"issue create alice/project"),
+            Ok(IssueCommand::Create(_))
+        ));
+        for command in [
+            b"issue list alice/project --output json extra".as_slice(),
+            b"issue list alice/project/extra".as_slice(),
+            b"issue create alice/project --output json --output json".as_slice(),
+            &[b'x'; MAX_ISSUE_COMMAND_BYTES + 1],
+        ] {
+            assert!(parse_issue_command(command).is_err());
+        }
+
+        let mut input = Vec::new();
+        assert!(append_issue_input(&mut input, &[b'x'; MAX_ISSUE_INPUT_BYTES]).is_ok());
+        assert!(append_issue_input(&mut input, b"x").is_err());
+        assert!(matches!(
+            parse_issue_input(b"invalid\xff"),
+            Err(IssueCommandError::Input)
+        ));
+    }
 }
diff --git a/tests/git_push_ssh.rs b/tests/git_push_ssh.rs
index b59e5efb19e0115bb93bf6b49122c6d014956cbd..4faed4cd492e47bebb2e71821a0f2598ced563b6 100644
--- a/tests/git_push_ssh.rs
+++ b/tests/git_push_ssh.rs
@@ -13,6 +13,9 @@
 )]
 #[path = "../src/git/mod.rs"]
 mod git;
+#[allow(dead_code, reason = "the SSH push test does not use issue commands")]
+#[path = "../src/issue.rs"]
+mod issue;
 #[allow(dead_code, reason = "the SSH push test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
diff --git a/tests/git_ssh.rs b/tests/git_ssh.rs
index da0c2555fe22c5d23eafb0d420f606c9c4d973b9..df9617f13731b8d2bc2f05272c373baaef67aa35 100644
--- a/tests/git_ssh.rs
+++ b/tests/git_ssh.rs
@@ -13,6 +13,9 @@
 )]
 #[path = "../src/git/mod.rs"]
 mod git;
+#[allow(dead_code, reason = "the SSH Git test does not use issue commands")]
+#[path = "../src/issue.rs"]
+mod issue;
 #[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
diff --git a/tests/serve.rs b/tests/serve.rs
index f85e9265842ebc607bac34d98029a0b0fb528972..e32b6d574430126bf54d9f3d0ea468e87446206f 100644
--- a/tests/serve.rs
+++ b/tests/serve.rs
@@ -613,6 +613,93 @@
     );
     assert!(machine.stderr.is_empty());
 
+    let created_issue = ssh_exec_with_input(
+        ssh,
+        &member_key,
+        &["issue", "create", "bob/example"],
+        b"First issue\nBody with **Markdown**.\n",
+    );
+    assert!(created_issue.status.success());
+    assert_eq!(
+        String::from_utf8(created_issue.stdout).expect("read issue create output"),
+        "Created issue bob/example#1.\n"
+    );
+    assert!(created_issue.stderr.is_empty());
+    let human_issues = ssh_exec(ssh, &member_key, &["issue", "list", "bob/example"]);
+    assert!(human_issues.status.success());
+    assert_eq!(
+        String::from_utf8(human_issues.stdout).expect("read human issue list"),
+        "#1 open First issue\n"
+    );
+    let machine_issues = ssh_exec(
+        ssh,
+        &member_key,
+        &["issue", "list", "bob/example", "--output", "json"],
+    );
+    assert!(machine_issues.status.success());
+    let machine_issues: serde_json::Value =
+        serde_json::from_slice(&machine_issues.stdout).expect("parse machine issue list");
+    assert_eq!(machine_issues["version"], 1);
+    assert_eq!(machine_issues["status"], "success");
+    assert_eq!(machine_issues["repository"]["owner"], "bob");
+    assert_eq!(machine_issues["repository"]["name"], "example");
+    assert_eq!(machine_issues["issues"][0]["number"], 1);
+    assert_eq!(machine_issues["issues"][0]["title"], "First issue");
+
+    let access_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+        .expect("open the issue access database");
+    access_database
+        .execute_batch("UPDATE repository SET visibility = 'private' WHERE slug = 'example';")
+        .expect("make the issue repository private");
+    let hidden = ssh_exec(
+        ssh,
+        &private_key,
+        &["issue", "list", "bob/example", "--output", "json"],
+    );
+    assert!(!hidden.status.success());
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&hidden.stdout)
+            .expect("parse hidden issue error")["error"]["code"],
+        "repository-unavailable"
+    );
+    access_database
+        .execute(
+            "INSERT INTO repository_collaborator
+                 (repository_id, account_id, role, created_at)
+             SELECT repository.id, account.id, 'reader', 10
+             FROM repository, account
+             WHERE repository.slug = 'example' AND account.username = 'alice'",
+            [],
+        )
+        .expect("give the administrator reader access");
+    drop(access_database);
+    let reader_create = ssh_exec_with_input(
+        ssh,
+        &private_key,
+        &["issue", "create", "bob/example", "--output", "json"],
+        b"Reader issue\nCreated through the shared service.",
+    );
+    assert!(reader_create.status.success());
+    let reader_create: serde_json::Value =
+        serde_json::from_slice(&reader_create.stdout).expect("parse machine issue create");
+    assert_eq!(reader_create["version"], 1);
+    assert_eq!(reader_create["status"], "success");
+    assert_eq!(reader_create["issue"]["number"], 2);
+    assert_eq!(reader_create["issue"]["author"], "alice");
+
+    let invalid_issue = ssh_exec_with_input(
+        ssh,
+        &private_key,
+        &["issue", "create", "bob/example", "--output", "json"],
+        b"\nbody without a title",
+    );
+    assert!(!invalid_issue.status.success());
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&invalid_issue.stdout)
+            .expect("parse invalid issue input")["error"]["code"],
+        "invalid-input"
+    );
+
     let duplicate = ssh_exec(
         ssh,
         &member_key,
@@ -660,6 +747,17 @@
         "{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
     );
     assert!(suspended.stderr.is_empty());
+    let suspended_issues = ssh_exec(
+        ssh,
+        &member_key,
+        &["issue", "list", "bob/example", "--output", "json"],
+    );
+    assert!(!suspended_issues.status.success());
+    assert_eq!(
+        serde_json::from_slice::<serde_json::Value>(&suspended_issues.stdout)
+            .expect("parse suspended issue output")["error"]["code"],
+        "repository-unavailable"
+    );
     server.terminate();
 
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
@@ -686,6 +784,44 @@
             ),
         ]
     );
+    let issues: Vec<(i64, String, String, String)> = database
+        .prepare(
+            "SELECT issue.number, issue.title, issue.body, account.username
+             FROM issue JOIN account ON account.id = issue.author_account_id
+             ORDER BY issue.number",
+        )
+        .expect("prepare the issue query")
+        .query_map([], |row| {
+            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
+        })
+        .expect("query created issues")
+        .collect::<Result<_, _>>()
+        .expect("read created issues");
+    assert_eq!(
+        issues,
+        vec![
+            (
+                1,
+                "First issue".to_owned(),
+                "Body with **Markdown**.\n".to_owned(),
+                "bob".to_owned(),
+            ),
+            (
+                2,
+                "Reader issue".to_owned(),
+                "Created through the shared service.".to_owned(),
+                "alice".to_owned(),
+            ),
+        ]
+    );
+    let issue_events: i64 = database
+        .query_row(
+            "SELECT count(*) FROM repository_event WHERE kind = 'issue-created'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("count issue create events");
+    assert_eq!(issue_events, 2);
     let audit: Vec<(String, String)> = database
         .prepare(
             "SELECT target, outcome FROM audit_event
@@ -1236,6 +1372,48 @@
         .args(command)
         .output()
         .expect("run an SSH repository command")
+}
+
+fn ssh_exec_with_input(
+    address: SocketAddr,
+    private_key: &Path,
+    command: &[&str],
+    input: &[u8],
+) -> Output {
+    let mut child = 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)
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .expect("start an SSH issue command");
+    child
+        .stdin
+        .take()
+        .expect("open SSH issue input")
+        .write_all(input)
+        .expect("write SSH issue input");
+    child
+        .wait_with_output()
+        .expect("finish an SSH issue command")
 }
 
 fn provision_account(
diff --git a/tests/ssh.rs b/tests/ssh.rs
index 8ef49d75e78d8c2e8b2b3adadbb2d71a62368e38..8fc2de34b893cd3c94c436942bea8b0f1b875caf 100644
--- a/tests/ssh.rs
+++ b/tests/ssh.rs
@@ -12,6 +12,12 @@
 mod git;
 #[allow(
     dead_code,
+    reason = "the SSH identity test does not use issue commands"
+)]
+#[path = "../src/issue.rs"]
+mod issue;
+#[allow(
+    dead_code,
     reason = "the SSH identity test does not use repository policy"
 )]
 #[path = "../src/policy.rs"]
