# Shell & PowerShell

> Drive the Cephable Automate HTTP Server from curl, bash, and PowerShell — one-liners, reusable helper functions, discovery, batch scripts, and scheduled task patterns for macOS, Linux, and Windows.

Source: https://developers.cephable.com/docs/automate-http-server/samples/shell

---
The fastest way to explore the API, and the easiest way to wire Cephable into cron, Task Scheduler, Shortcuts, Automator, Stream Deck, or a Makefile.

```bash
export CEPHABLE_AUTOMATE_KEY='paste-key-from-the-extension-detail-view'
export CEPHABLE_ENDPOINT='http://127.0.0.1:4317'
```

```powershell
$env:CEPHABLE_AUTOMATE_KEY = 'paste-key-from-the-extension-detail-view'
$env:CEPHABLE_ENDPOINT = 'http://127.0.0.1:4317'
```

> Do not put the key in the command itself — process command lines are readable by other local processes, and it lands in your shell history. Use an environment variable or read it from a `0600` file.

---

## curl one-liners

**Health check** — the first thing to run when anything is wrong:

```bash
curl -sS "$CEPHABLE_ENDPOINT/health" -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" | jq
```

**Run a task:**

```bash
curl -sS --max-time 960 "$CEPHABLE_ENDPOINT/v1/runs" \
  -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "prompt": "List the three largest files in my Downloads folder with their sizes.",
        "include": { "trace": false, "events": false }
      }' | jq -r '.answer'
```

`--max-time` matters: curl has no default timeout, but your shell script or scheduler might kill it. Keep it above the run's `timeoutMs` (15 minutes by default).

**Just the contract value:**

```bash
curl -sS "$CEPHABLE_ENDPOINT/v1/runs" \
  -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" -H "Content-Type: application/json" \
  -d '{ "prompt": "How many PDFs are in my Documents folder?",
        "answerContract": "Reply with exactly: FINAL ANSWER: <integer>",
        "include": { "steps": false, "trace": false, "events": false } }' \
  | jq -r '.finalAnswer'
```

**What did it actually do?**

```bash
curl -sS "$CEPHABLE_ENDPOINT/v1/runs" \
  -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" -H "Content-Type: application/json" \
  -d '{ "prompt": "Organize the screenshots in my Desktop folder into a Screenshots subfolder." }' \
  | jq -r '.steps[] | "\(.index). \(.title) [\(.toolName)] -> \(.status)"'
```

**Models:**

```bash
curl -sS "$CEPHABLE_ENDPOINT/v1/automate/models" -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" \
  | jq -r '.data[] | select(.downloaded and .supportsTools) | "\(.name) (\(.sizeCode)) selected=\(.selected)"'
```

**Pin a model** (once per session, not per run):

```bash
curl -sS "$CEPHABLE_ENDPOINT/v1/automate/models/select" \
  -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" -H "Content-Type: application/json" \
  -d '{ "family": "Gemma", "sizeCode": "M" }' | jq '.selected.name'
```

**Stop whatever is running** — safe to call unconditionally:

```bash
curl -sS "$CEPHABLE_ENDPOINT/v1/automate/cancel" \
  -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" -H "Content-Type: application/json" \
  -d '{ "force": true }' | jq
```

**A prompt too long for a command line** — put it in a file:

```bash
jq -n --rawfile prompt ./task.md \
   '{ prompt: $prompt, include: { trace: false, events: false } }' \
  | curl -sS "$CEPHABLE_ENDPOINT/v1/runs" \
      -H "Authorization: Bearer $CEPHABLE_AUTOMATE_KEY" -H "Content-Type: application/json" \
      -d @- | jq -r '.answer'
```

Bodies are capped at 1 MiB. For large inputs, write the data to the workspace folder and have the run read it from there instead of inlining it.

---

## Reusable bash helpers

```bash
#!/usr/bin/env bash
# cephable.sh — source this, or run it directly with a prompt argument.
set -euo pipefail

: "${CEPHABLE_AUTOMATE_KEY:?Set CEPHABLE_AUTOMATE_KEY}"
CEPHABLE_BASE_PORT=4317
CEPHABLE_PORT_ATTEMPTS=12

# Cephable moves to the next free port when its preferred one is taken, so sweep the same
# window the app uses and confirm the service name before trusting a listener.
cephable_discover() {
  if [[ -n "${CEPHABLE_ENDPOINT:-}" ]]; then printf '%s' "$CEPHABLE_ENDPOINT"; return 0; fi
  local port candidate body
  for (( i = 0; i < CEPHABLE_PORT_ATTEMPTS; i++ )); do
    port=$(( CEPHABLE_BASE_PORT + i ))
    candidate="http://127.0.0.1:${port}"
    body=$(curl -sS --max-time 3 "${candidate}/health" \
      -H "Authorization: Bearer ${CEPHABLE_AUTOMATE_KEY}" 2>/dev/null) || continue
    if [[ "$(printf '%s' "$body" | jq -r '.service // empty')" == "cephable-agent" ]]; then
      printf '%s' "$candidate"; return 0
    fi
  done
  echo "No Cephable Automate server answered on 127.0.0.1:${CEPHABLE_BASE_PORT}-$(( CEPHABLE_BASE_PORT + CEPHABLE_PORT_ATTEMPTS - 1 )).
Open Cephable and enable Extensions > Cephable features > Build & Extend > Automate HTTP Server." >&2
  return 1
}

cephable_health() {
  local endpoint; endpoint=$(cephable_discover)
  curl -sS --max-time 10 "${endpoint}/health" -H "Authorization: Bearer ${CEPHABLE_AUTOMATE_KEY}"
}

# The user's own panel runs hold the single inference slot too, so wait rather than 409.
cephable_wait_ready() {
  local timeout="${1:-300}" deadline health
  deadline=$(( $(date +%s) + timeout ))
  while (( $(date +%s) < deadline )); do
    health=$(cephable_health)
    if [[ "$(printf '%s' "$health" | jq -r '.busy')" == "false" ]] &&
       [[ "$(printf '%s' "$health" | jq -r '.workflowStatus')" =~ ^(idle|terminated)$ ]]; then
      printf '%s' "$health"; return 0
    fi
    sleep 1
  done
  echo "Cephable stayed busy for ${timeout}s" >&2
  return 1
}

cephable_cancel() {
  local endpoint force; endpoint=$(cephable_discover); force="${1:-false}"
  curl -sS --max-time 30 "${endpoint}/v1/automate/cancel" \
    -H "Authorization: Bearer ${CEPHABLE_AUTOMATE_KEY}" -H "Content-Type: application/json" \
    -d "{\"force\": ${force}}"
}

# cephable_run "<prompt>" [timeoutMs] [restrictToWorkspace]
# Prints the run record. Exit 0 only when status is "completed".
cephable_run() {
  local prompt="$1" timeout_ms="${2:-600000}" restrict="${3:-false}"
  local endpoint payload response status
  endpoint=$(cephable_discover)

  payload=$(jq -n \
    --arg prompt "$prompt" \
    --argjson timeoutMs "$timeout_ms" \
    --argjson restrict "$restrict" \
    '{ prompt: $prompt, timeoutMs: $timeoutMs, restrictToWorkspace: $restrict,
       include: { steps: true, trace: false, events: false } }')

  response=$(curl -sS --max-time $(( timeout_ms / 1000 + 30 )) "${endpoint}/v1/runs" \
    -H "Authorization: Bearer ${CEPHABLE_AUTOMATE_KEY}" -H "Content-Type: application/json" \
    -d "$payload")

  # A failed run is HTTP 500 with a COMPLETE record; only a body without schemaVersion
  # means no run happened.
  if [[ "$(printf '%s' "$response" | jq -r '.schemaVersion // empty')" != "1" ]]; then
    printf '%s' "$response" | jq -r '.error.message // "The request failed"' >&2
    return 2
  fi

  printf '%s\n' "$response"
  status=$(printf '%s' "$response" | jq -r '.status')
  [[ "$status" == "completed" ]]
}

# Direct invocation: ./cephable.sh "do the thing"
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  cephable_wait_ready 300 >/dev/null
  cephable_run "$1" | jq -r '.finalAnswer // .answer'
fi
```

Usage:

```bash
source ./cephable.sh
cephable_health | jq '{appVersion, modelName, workflowStatus, busy}'
cephable_wait_ready 120 >/dev/null
cephable_run "Summarize ~/notes/today.md in three bullets." | jq -r '.answer'
```

---

## PowerShell helpers

```powershell
# Cephable.psm1
$script:BasePort = 4317
$script:PortAttempts = 12

function Get-CephableKey {
    if (-not $env:CEPHABLE_AUTOMATE_KEY) { throw 'Set CEPHABLE_AUTOMATE_KEY' }
    return $env:CEPHABLE_AUTOMATE_KEY
}

function Resolve-CephableEndpoint {
    <#  Cephable moves to the next free port when its preferred one is taken,
        so sweep the same window and confirm the service name. #>
    if ($env:CEPHABLE_ENDPOINT) { return $env:CEPHABLE_ENDPOINT }
    $key = Get-CephableKey
    for ($offset = 0; $offset -lt $script:PortAttempts; $offset++) {
        $candidate = "http://127.0.0.1:$($script:BasePort + $offset)"
        try {
            $health = Invoke-RestMethod -Uri "$candidate/health" -TimeoutSec 3 `
                -Headers @{ Authorization = "Bearer $key" }
            if ($health.service -eq 'cephable-agent') { return $candidate }
        } catch {
            # A 401 still proves a server is listening here. StatusCode is a WebException int in
            # PowerShell 5.1 and an HttpStatusCode enum in 7+, so compare the stringified value.
            $code = "$($_.Exception.Response.StatusCode)"
            if ($code -eq '401' -or $code -eq 'Unauthorized') {
                throw "Cephable is listening on $candidate but rejected the access key"
            }
            # Nothing listening here; keep sweeping.
        }
    }
    throw "No Cephable Automate server answered on 127.0.0.1:$($script:BasePort)-$($script:BasePort + $script:PortAttempts - 1). Enable Extensions > Cephable features > Build & Extend > Automate HTTP Server."
}

function Get-CephableHealth {
    $key = Get-CephableKey
    Invoke-RestMethod -Uri "$(Resolve-CephableEndpoint)/health" -TimeoutSec 10 `
        -Headers @{ Authorization = "Bearer $key" }
}

function Wait-CephableReady {
    param([int] $TimeoutSeconds = 300)
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        $health = Get-CephableHealth
        if (-not $health.busy -and $health.workflowStatus -in @('idle', 'terminated')) { return $health }
        Start-Sleep -Seconds 1
    }
    throw "Cephable stayed busy for $TimeoutSeconds seconds"
}

function Stop-CephableRun {
    param([switch] $Force)
    $key = Get-CephableKey
    Invoke-RestMethod -Method Post -Uri "$(Resolve-CephableEndpoint)/v1/automate/cancel" -TimeoutSec 30 `
        -Headers @{ Authorization = "Bearer $key" } -ContentType 'application/json' `
        -Body (@{ force = [bool] $Force } | ConvertTo-Json)
}

function Invoke-CephableRun {
    param(
        [Parameter(Mandatory)] [string] $Prompt,
        [string] $TaskId,
        [string] $AnswerContract,
        [int] $TimeoutMs = 600000,
        [switch] $RestrictToWorkspace,
        [switch] $AllowDestructiveTools
    )

    $key = Get-CephableKey
    $body = @{
        prompt                = $Prompt
        timeoutMs             = $TimeoutMs
        restrictToWorkspace   = [bool] $RestrictToWorkspace
        allowDestructiveTools = [bool] $AllowDestructiveTools
        include               = @{ steps = $true; trace = $false; events = $false }
    }
    if ($TaskId) { $body.taskId = $TaskId }
    if ($AnswerContract) { $body.answerContract = $AnswerContract }

    try {
        Invoke-RestMethod -Method Post -Uri "$(Resolve-CephableEndpoint)/v1/runs" `
            -Headers @{ Authorization = "Bearer $key" } -ContentType 'application/json' `
            -Body ($body | ConvertTo-Json -Depth 6) -TimeoutSec ([int]($TimeoutMs / 1000) + 30)
    } catch {
        # A failed run is HTTP 500 with a COMPLETE record, so read the body before deciding.
        # PowerShell 7 puts it in ErrorDetails.Message; 5.1 needs the response stream.
        $text = $_.ErrorDetails.Message
        if (-not $text -and $null -ne $_.Exception.Response) {
            $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
            $text = $reader.ReadToEnd()
        }
        $parsed = $null
        if ($text) { try { $parsed = $text | ConvertFrom-Json } catch { } }
        if ($null -ne $parsed -and $parsed.schemaVersion -eq 1) { return $parsed }
        if ($null -ne $parsed -and $parsed.error) { throw $parsed.error.message }
        throw
    }
}

Export-ModuleMember -Function Get-CephableHealth, Resolve-CephableEndpoint, Wait-CephableReady,
                              Stop-CephableRun, Invoke-CephableRun
```

Usage:

```powershell
Import-Module .\Cephable.psm1

Get-CephableHealth | Select-Object appVersion, modelName, workflowStatus, busy
Wait-CephableReady | Out-Null

$result = Invoke-CephableRun -Prompt 'Count the PDFs in my Documents folder.' `
                             -AnswerContract 'Reply with exactly: FINAL ANSWER: <integer>'

if ($result.status -eq 'completed') {
    $result.finalAnswer
} else {
    Write-Error "Run $($result.status): $($result.errorCode)"
    $result.steps | Where-Object status -eq 'failed' |
        ForEach-Object { Write-Error "  $($_.toolName): $($_.resultSummary)" }
}
```

---

## Scheduled and unattended runs

The same rules apply whether it is cron, Task Scheduler, or a CI job:

- **Confine to the workspace.** `restrictToWorkspace: true` on anything unattended.
- **Keep destructive tools off** unless you have a sandboxed profile and trusted inputs.
- **Wait for readiness**, do not retry into a `409`.
- **Cancel on failure**, so an abandoned run does not block the user's next one.
- **Read the key from a protected file**, not the crontab line.

### cron (macOS / Linux)

```bash
#!/usr/bin/env bash
# nightly-report.sh
set -euo pipefail
export CEPHABLE_AUTOMATE_KEY="$(< "$HOME/.config/cephable/automate.key")"   # chmod 600
source "$(dirname "$0")/cephable.sh"

trap 'cephable_cancel true >/dev/null 2>&1 || true' ERR

cephable_wait_ready 600 >/dev/null
cephable_run "Read every .csv in this folder, total the Amount column per file, and write results.md." \
             900000 true \
  | tee "$HOME/reports/$(date +%F).json" \
  | jq -r '"\(.status) in \(.durationMs)ms"'
```

```cron
30 2 * * * /usr/local/bin/nightly-report.sh >> ~/logs/cephable.log 2>&1
```

Cephable must be running and signed in for the job to succeed — the server is part of the app, not a background service. On a machine that logs out, schedule it in the user session rather than as a system task.

### Windows Task Scheduler

```powershell
# nightly-report.ps1
$ErrorActionPreference = 'Stop'
$env:CEPHABLE_AUTOMATE_KEY = Get-Content "$env:LOCALAPPDATA\cephable\automate.key" -Raw
Import-Module "$PSScriptRoot\Cephable.psm1"

try {
    Wait-CephableReady -TimeoutSeconds 600 | Out-Null
    $result = Invoke-CephableRun -Prompt 'Read every .csv in this folder and write results.md.' `
                                 -TaskId "nightly-$(Get-Date -Format yyyy-MM-dd)" `
                                 -RestrictToWorkspace -TimeoutMs 900000
    $result | ConvertTo-Json -Depth 8 |
        Set-Content "$env:USERPROFILE\reports\$(Get-Date -Format yyyy-MM-dd).json" -Encoding utf8
    if ($result.status -ne 'completed') { throw "Run $($result.status): $($result.errorCode)" }
} catch {
    Stop-CephableRun -Force | Out-Null
    throw
}
```

Register it to run **only when the user is logged on** — the app has to be running:

```powershell
$action  = New-ScheduledTaskAction -Execute 'powershell.exe' `
             -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\scripts\nightly-report.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 2:30am
Register-ScheduledTask -TaskName 'Cephable nightly report' -Action $action -Trigger $trigger
```

---

## macOS Shortcuts and Automator

A **Run Shell Script** action turns any Shortcut into an Automate task. Read the key from the keychain rather than embedding it:

```bash
#!/bin/bash
# Store once:  security add-generic-password -a "$USER" -s cephable-automate -w 'the-key'
KEY=$(security find-generic-password -a "$USER" -s cephable-automate -w)

curl -sS --max-time 960 "http://127.0.0.1:4317/v1/runs" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg p "$1" '{prompt: $p, include: {steps: false, trace: false, events: false}}')" \
  | jq -r '.answer'
```

Pass the Shortcut's input as `$1`, and put the result on the clipboard or into a notification. Because the assistant can type, click, and read the screen, the pairing with an accessible input — a switch, a sensor, a single Shortcut button — is the point of the whole feature.
