DEV Community

Solon Framework
Solon Framework

Posted on

SolonCode in CI: Headless Runs, JSONL Events, and Remote Workspace Execution

Interactive coding agents are designed for a person at a keyboard. CI is not. A pipeline needs a process it can start, observe, constrain, and classify without waiting for a human to approve the next tool call.

SolonCode has a separate run entry point for that boundary. It is a one-shot, non-interactive execution mode: give it a prompt, let the agent work to a terminal result, consume text or structured output, and use the exit code to decide what the pipeline should do next.

The useful mental model is not “a smaller chat UI.” It is “an agent process with a machine-readable contract.”

The smallest useful CI invocation

soloncode run "Review the changes in this pull request. Focus on security and correctness." \\
  --output-format json \\
  --allowedTools "Read,Grep,Glob,Bash(git log *),Bash(git diff *)" \\
  --disallowedTools "Bash(rm *)" \\
  --permission-mode dontAsk \\
  --max-turns 15 \\
  --max-budget-usd 2.0 > review.json
Enter fullscreen mode Exit fullscreen mode

This command makes several choices explicit:

  • run does not start the interactive UI.
  • json produces one result object for a script to parse.
  • The tool allowlist describes what the reviewer may inspect.
  • The command rule blocks destructive rm calls while allowing selected Git reads.
  • dontAsk is appropriate for an unattended job: an operation requiring approval is not turned into a hidden prompt.
  • max-turns provides a runtime bound.
  • max-budget-usd gives the result a cost threshold.

A CI wrapper should check the process status before trusting the output:

set +e
soloncode run "Review the changes in this PR" \\
  --output-format json \\
  --allowedTools "Read,Grep,Glob,Bash(git diff *)" \\
  --permission-mode dontAsk \\
  --max-turns 15 \\
  --max-budget-usd 2.0 > review.json
status=$?
set -e

jq -r '.error // .result' review.json

case "$status" in
  0) echo "review completed" ;;
  2) echo "agent reached the maximum number of turns" >&2; exit 1 ;;
  4) echo "agent exceeded the configured budget" >&2; exit 1 ;;
  *) echo "agent execution failed" >&2; exit 1 ;;
esac
Enter fullscreen mode Exit fullscreen mode

The distinction between the exit code and the JSON body matters. A result can be valid JSON and still represent an incomplete or over-budget execution.

Three output modes, three consumers

soloncode run supports three output formats.

text: a human-oriented result

The default writes the final answer as plain text. It is useful when a shell script only needs to display a report or append it to a log, but it is not a stable envelope for downstream automation.

json: one terminal object

JSON is the natural choice for a job that waits for completion. The object can contain the result, error state, session ID, metrics, estimated cost, and—when a schema is supplied—structured_output.

soloncode run "List public API methods in src" \\
  --output-format json \\
  --allowedTools "Read,Grep,Glob" \\
  --permission-mode dontAsk \\
  --json-schema '{"type":"object","properties":{"methods":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"file":{"type":"string"}}}}}}' \\
  | jq '.structured_output.methods'
Enter fullscreen mode Exit fullscreen mode

The schema is an output constraint, not a permission policy. Keep the two concerns separate: json-schema describes what the answer should look like; tool and permission options describe what the agent may do while producing it.

stream-json: an observable event stream

For longer tasks, waiting silently for one final object is a poor operational experience. With stream-json --verbose, SolonCode emits one JSON object per line. The event stream includes initialization, assistant text, tool calls, tool results, and a terminal result or error.

soloncode run "Inspect the build and explain the first failing test" \\
  --output-format stream-json --verbose \\
  | tee run.jsonl \\
  | jq -r 'select(.type=="result") | .result'
Enter fullscreen mode Exit fullscreen mode

Because each event is a line, a pipeline can retain the complete trace and independently project the part it needs:

# Observe tool calls without parsing the final prose
jq 'select(.type=="assistant")
    | .message.content[]?
    | select(.type=="tool_use")
    | {name, input}' run.jsonl
Enter fullscreen mode Exit fullscreen mode

Without --verbose, stream-json emits only the final result event. That behavior is useful when the caller wants the stream-shaped terminal record but does not need intermediate activity.

Prompt input: argv or stdin

A prompt can be the positional argument or come from a redirected stdin stream. If both are present, the command-line prompt wins.

cat build-error.log | soloncode run \\
  --output-format json \\
  "Analyze this build failure and identify its root cause"
Enter fullscreen mode Exit fullscreen mode

This is more than a convenience. Keeping large or generated input out of shell interpolation avoids quoting surprises and makes it possible for a CI step to pass a report directly into the agent.

run is deliberately one-shot. It does not accept the persistent JSONL input mode. If a process must remain alive and receive multiple user messages from stdin, the separate soloncode stream entry point is the clearer lifecycle contract.

Tool restrictions are part of the job definition

A prompt saying “do not change files” is not an enforcement mechanism. In unattended execution, restrictions should be represented by options.

For a read-only review:

soloncode run "Review this repository" \\
  --allowedTools "Read,Grep,Glob" \\
  --permission-mode dontAsk
Enter fullscreen mode Exit fullscreen mode

For a plan-only task:

soloncode run "Propose a migration plan for the authentication module" \\
  --permission-mode plan
Enter fullscreen mode Exit fullscreen mode

For a narrowly scoped automatic edit, acceptEdits allows file-oriented tools while other operations remain rejected by the permission rules. It should still be combined with a specific allowlist and a turn limit.

The ToolName(pattern) form is useful when a whole tool is too broad:

--allowedTools "Read,Grep,Bash(git diff *),Bash(git log *)"
--disallowedTools "Bash(rm *)"
Enter fullscreen mode Exit fullscreen mode

The pattern is matched as a tool command rule. This is preferable to allowing arbitrary shell execution merely because the task needs git diff.

Bound the agent in three dimensions

A robust CI invocation usually has three independent bounds:

  1. Capability bound — which tools and commands can be called.
  2. Work bound — how many reasoning/action turns may run.
  3. Cost bound — how much estimated usage the result may report.

--max-turns is the runtime guard. --max-budget-usd is currently checked after execution completes, so it should not be treated as a hard mid-run kill switch. Use both, and make the pipeline’s policy explicit for exit code 4.

--fallback-model can make a scheduled job more tolerant of primary-model unavailability, but it does not remove the need for bounded work and a clear output contract:

soloncode run "Run the nightly code-health inspection" \\
  --output-format json \\
  --allowedTools "Read,Grep,Glob,Bash(git log *)" \\
  --permission-mode dontAsk \\
  --max-turns 25 \\
  --max-budget-usd 3.0 \\
  --fallback-model haiku \\
  > reports/health.json
Enter fullscreen mode Exit fullscreen mode

Two phases with a resumable session

Some jobs are easier to reason about when analysis and modification are separate phases. The JSON result exposes a session ID that can be used by a later invocation:

set -euo pipefail

soloncode run "Analyze src/auth and list the risks" \\
  --output-format json --max-turns 10 > phase1.json

session=$(jq -r '.session_id' phase1.json)

soloncode run "Using that analysis, write unit tests for src/auth" \\
  --resume "$session" \\
  --output-format json \\
  --permission-mode acceptEdits \\
  --max-turns 20 > phase2.json
Enter fullscreen mode Exit fullscreen mode

The session is an agent conversation boundary, not a replacement for Git branches, worktrees, or artifact storage. If the second phase edits files, the CI job still needs its normal diff and test checks.

Remote execution: the same contract behind HTTP

A local shell is not always the right integration point. A build service may already run SolonCode on a worker, or a Java application may want to submit work without installing the CLI.

The /web/run endpoint carries the same one-shot execution contract over HTTP:

{
  "prompt": "Analyze this module",
  "options": {
    "output_format": "stream-json",
    "max_turns": 15,
    "allowed_tools": ["Read", "Grep", "Bash(git diff *)"],
    "permission_mode": "dontAsk"
  },
  "workspace": "my-project",
  "metadata": {"request_id": "ci-001"}
}
Enter fullscreen mode Exit fullscreen mode

The HTTP representation uses snake_case, while the CLI keeps its flag spelling. Unknown option fields are rejected instead of silently ignored. That failure mode is important: a typo must not make a caller believe that a safety option was applied.

For streaming requests, the response is SSE. Each data: line contains the same JSON event that the CLI would write as one JSONL line, so an existing event parser can be reused:

curl -N -X POST http://127.0.0.1:18080/web/run \\
  -H "Authorization: Bearer $TOKEN" \\
  -H "Content-Type: application/json" \\
  -H "Accept: text/event-stream" \\
  -d '{"prompt":"Analyze code quality","options":{"output_format":"stream-json"}}' \\
  | grep '^data:' \\
  | sed 's/^data: *//' \\
  | jq -r 'select(.type=="result") | .result'
Enter fullscreen mode Exit fullscreen mode

The server implementation starts a child App run process in the selected workspace. That choice preserves the CLI’s argument parsing and execution semantics while keeping per-request engine options from mutating the web process’s interactive engine. It also means a request pays a JVM startup cost; the isolation and zero-drift properties are the more important trade-off for this boundary.

The endpoint adds network-specific semantics:

  • Bearer authentication is required.
  • The server accepts registered workspace identifiers, not arbitrary paths.
  • bypassPermissions is rejected for /web/run.
  • A session already running receives a conflict response rather than interleaving messages.
  • /web/run/interrupt returns 202 and destroys the active child process.
  • A client disconnect is treated as a reason to stop work, not as permission to keep an orphaned agent running.

Do not expose this endpoint as an unauthenticated general-purpose shell. It drives an agent with file and command capabilities. Loopback binding, token authentication, workspace restrictions, permission-mode narrowing, and audit logging are security requirements, not optional deployment polish.

Exit codes are an API

The local and remote forms intentionally distinguish execution conclusions from transport failures.

Exit code Meaning
0 Completed successfully
1 Agent or API runtime error
2 Maximum turn limit reached
3 No prompt was supplied
4 Estimated cost exceeded the configured budget

For /web/run, success is HTTP 200. A runtime error maps to HTTP 500, and a missing prompt to HTTP 400. Reaching the turn or budget limit remains HTTP 200 with an error state in the result, because the request was accepted and executed; the client should inspect the payload rather than blindly retrying at the HTTP layer.

That distinction prevents a common automation bug: retrying an expensive, already-completed-but-incomplete agent run as if the network had failed.

A practical adoption sequence

Start small:

  1. Run a read-only task locally with json output.
  2. Add max-turns and an explicit tool allowlist.
  3. Make the CI step archive the JSON result and check the exit code.
  4. Add a schema when another program needs fields, not prose.
  5. Use stream-json when operators need progress or an audit trace.
  6. Split analysis and edits with --resume only after the single-phase job is reliable.
  7. Move to /web/run when execution must live on a service worker, and retain the same parser and policy checks.

The central design decision is to treat the agent invocation as a typed, bounded job. Prompts provide intent; options define capability and limits; events provide observability; exit codes provide control flow. That is what makes SolonCode useful in CI rather than merely runnable from a CI shell.

Top comments (0)