# Custom Job Guide

## Custom Job Integration Guide

This guide walks you through integrating a custom executable with KEPM as a job task — from building and deploying your binary, to authoring the job JSON, to connecting to MQTT and publishing structured logs. It covers everything a tool that runs on a schedule or at agent startup needs to work correctly in a production deployment.

If you have not read the [Integrations Overview](/keeperpam/endpoint-privilege-manager/integrations/overview.md) page yet, start there. It explains the key terms, how the pieces connect, and when a job task is the right pattern for your use case.

{% stepper %}
{% step %}
**Build and Deploy Your Binary**

**What to Build**

Your binary can be written in any language. The agent does not impose framework or runtime requirements — it starts your executable as a child process, tracks its exit code, and captures its output. The expected pattern is simple: start, do work, exit with a meaningful exit code.

**A few things to plan from the start:**

* **One build per OS.** If you support Windows, Linux, and macOS, produce a separate binary for each platform. You can use one job JSON per OS or one job with multiple tasks and conditions, but separate builds per platform is simpler and easier to maintain.
* **Single purpose.** Scan, report, or perform a maintenance task — then exit. Avoid designing a job task binary as a long-running daemon; that is what the plugin pattern is for.
* **Log via MQTT.** Plan to publish structured log messages to the `KeeperLogger` MQTT topic rather than relying on stdout. [Step 3](#author-the-job-json) covers the full details.

**Deploy the Binary on the Endpoint**

Install your binary under the agent's application root. The recommended layout allows the agent to resolve your binary by name without an explicit full path:

```
{AgentRoot}/Jobs/bin/{CommandName}/{CommandName}.exe    (Windows)
{AgentRoot}/Jobs/bin/{CommandName}/{CommandName}        (Linux / macOS)
```

In the job JSON, set the task's `command` field to `{CommandName}` (no path). The job runner checks `Jobs/bin/{CommandName}/` before searching `PATH`.

If you cannot use this layout, set `executablePath` to a full absolute path. You still need to set `command` — it is required by the job schema — but `executablePath` takes precedence for resolution.

**Cross-Platform Jobs and `osFilter`**

Use `osFilter` on the job root so each endpoint only runs the task that matches its operating system. When `osFilter` excludes the current OS, the validator also skips checking whether that task's binary exists on disk — which means you can deploy a Windows job to a fleet that includes Linux agents without causing validation failures.

```json
"osFilter": {
  "windows": true,
  "linux": false,
  "macOS": false
}
```

For multi-OS support, the simplest approach is a separate job JSON file per platform (for example `my-tool-windows.json`, `my-tool-linux.json`), each with its own binary path and `osFilter`. A single job with multiple tasks and `osFilter` conditions on each task is also supported but is more complex to maintain.

**Execution Context**

Set `ExecutionType` on the task to control which account the agent uses when starting your process.

<table data-header-hidden="false" data-header-sticky><thead><tr><th width="144.54534912109375">ExecutionType</th><th>When to use</th></tr></thead><tbody><tr><td><code>Service</code></td><td>The default. Runs as the agent service account. Use this for background tasks — security scans, maintenance jobs, compliance reporters — that need machine-wide access but not an interactive user session.</td></tr><tr><td><code>User</code></td><td>Runs in the logged-on user's session. Use only when your tool genuinely needs user profile access interactively. Note that user-session processes are subject to additional trust checks described in the next section.</td></tr><tr><td><code>UserDesktop</code></td><td>Runs in the user's desktop session. Same caveats as <code>User</code>.</td></tr></tbody></table>

For most custom tools, `Service` is the right choice. If you use `User` or `UserDesktop`, read the signing and process trust section carefully — non-admin user-session processes are subject to additional restrictions.
{% endstep %}

{% step %}
**Code Signing and Process Trust**

This is one of the most important things to understand before you write a single line of job JSON. How the agent decides to trust your binary affects whether MQTT connections succeed and whether Plugin-tier HTTPS calls return data or `403`.

**How the Agent Registers Your Process**

When the job runner starts your binary as a task, it adds that process to the agent's launched-process registry immediately after starting it. This registration is what grants your process:

* Access to the local MQTT broker
* Permission to call Plugin-tier HTTPS endpoints like `/api/PluginSettings/...`

Because the registration happens before your binary makes its first connection, a normally launched job task does not need to pass a certificate check to connect to MQTT or call Plugin Settings.

**When Certificate Checks Run**

Certificate checks run when the agent encounters a process that is **not** already in the launched-process registry. This happens in two cases:

1. **Your binary starts before the job runner's registration completes** (a race condition on startup). The agent then falls back to a certificate-based check. The fix is a short retry loop on the first MQTT connection.
2. **Your binary is started manually** — from a shell, a script, or Explorer — rather than by a job. In this case the agent must validate the executable's signature against the Keeper Privilege Manager certificate or any thumbprints listed in `Settings:AlternativeSignatures`. An unsigned binary fails this path.

**Adding Your Certificate Thumbprint**

If your binary needs to connect to MQTT or call Plugin Settings when started outside a job (for example during development or testing), add your code-signing certificate thumbprint to `Settings:AlternativeSignatures` in `appsettings.json`:

```json
{
  "Settings": {
    "AlternativeSignatures": [
      "A1B2C3D4E5F6789012345678901234567890ABCD"
    ]
  }
}
```

To get the thumbprint on Windows:

```powershell
Get-AuthenticodeSignature -FilePath "C:\Path\To\YourTool.exe" |
  Select-Object -ExpandProperty SignerCertificate |
  Select-Object -ExpandProperty Thumbprint
```

Use the thumbprint as a 40-character hex string with no spaces. You can list multiple thumbprints if you have more than one signing certificate.

Restart the Keeper Privilege Manager service after saving `appsettings.json`. These settings are read at startup.

**`AllowedNonAdminExecutables` for User-Session Processes**

If your binary runs in a user session (`ExecutionType: User` or `UserDesktop`) and the process owner is not an administrator, one additional check applies after the certificate check succeeds: the executable's base name (no path, no `.exe`) must appear in `Settings:AllowedNonAdminExecutables`.

```json
{
  "Settings": {
    "AllowedNonAdminExecutables": [
      "MyTool"
    ]
  }
}
```

This check does not apply to `Service` tasks, which run under the agent service account and follow system-session trust rules. If you only run your tool as a `Service` job task, you do not need to configure this setting.

**Recommended Practice**

1. **Sign all production builds** with your organization's code-signing identity (Windows Authenticode; Apple Developer ID on macOS; GPG-signed packages or IMA signing on Linux where your deployment enforces it).
   1. On Linux, the agent's process trust relies primarily on the job runner launching your binary — ensure the binary is always started via a job task rather than manually to avoid the certificate check path.
2. **Add your thumbprint** to `Settings:AlternativeSignatures` when MQTT or Plugin Settings access must work outside a job-launched context.
3. **Keep job binaries under `Jobs/`**, not under `Plugins/`. Plugin directory paths are subject to stricter validation rules that do not apply to binaries under `Jobs/bin/`.
4. **Always run as `Service`** unless you have a specific reason to use a user-session execution type.
   {% endstep %}

{% step %}
**Author the Job JSON**

**The Job File and its Location**

A job is a JSON file stored at:

```
{AgentRoot}/Jobs/{job-id}.json
```

The `id` field inside the file must exactly match the filename without the `.json` extension. For example, `secrets-scanner.json` must contain `"id": "secrets-scanner"`.

**Important:** Use hyphens in job IDs, not underscores. The MQTT broker parses the job ID out of the client ID by splitting on underscores. An ID like `secrets_scanner` will break that parsing and can cause publish permission failures.

**Last Known Good and How to Deploy Safely**

In most deployments, the agent runs with Last Known Good (`ConfigurationLkg`) enabled. This means the agent keeps an encrypted reference copy of each job's JSON and watches the `Jobs/` directory for drift from that reference. If it detects a file that doesn't match the stored copy — for example because someone edited it by hand — it restores the file from the reference copy, undoing the change.

For this reason, **do not deploy jobs by dropping files directly into `Jobs/`**. Use one of the two trusted write paths instead:

**Option 1: The local HTTPS API.** Post the job JSON to `POST /api/Jobs` on the local agent. This writes both the file and the Last Known Good reference in a single atomic operation. See [Step 4](#deploy-the-job) for the API details.

**Option 2: A `JobUpdate` policy.** In managed fleet environments, a `JobUpdate` policy delivered through the Keeper console is the standard path. The Configuration Policy Processor writes the job file and updates the reference copy as a blessed operation. Contact your Keeper administrator for the current policy schema and console workflow.

**Anatomy of a Job JSON**

Here is a complete, annotated example for a Windows job:

```json
{
  "id": "secrets-scanner",
  "name": "Secrets Scanner",
  "description": "Scans for exposed credentials and publishes results to KeeperLogger.",
  "enabled": true,

  "schedule": {
    "intervalMinutes": 120
  },

  "events": [
    { "eventType": "Startup" }
  ],

  "osFilter": {
    "windows": true,
    "linux": false,
    "macOS": false
  },

  "mqttTopics": {
    "allowedPublications": ["KeeperLogger"],
    "allowedSubscriptions": []
  },

  "parameters": [],

  "tasks": [
    {
      "id": "run-scanner",
      "name": "Run scanner",
      "ExecutionType": "Service",
      "command": "SecretScanner",
      "executablePath": "C:\\Program Files\\KeeperPrivilegeManager\\Jobs\\bin\\SecretScanner\\SecretScanner.exe",
      "arguments": "--scan --keeper-api-base={KeeperApiBaseUrl}",
      "timeoutSeconds": 3600,
      "continueOnFailure": false,
      "scriptType": "Auto"
    }
  ]
}
```

The key fields:

**`id`** — Must match the filename. Use hyphens only; no underscores.

**`enabled`** — Set to `true`. A job with `enabled: false` will not run, even if triggered manually.

**`schedule`** — Defines the timer trigger. `intervalMinutes` is minutes *between* runs, not clock-aligned wall time. `120` means every two hours. See the [schedule options](#schedule-options) section below for other modes.

**`events`** — Defines event triggers. The `Startup` event fires once when the agent starts, after jobs are loaded. A job can have both a `schedule` and `events` — either can trigger execution independently.

**`osFilter`** — Controls which operating systems run the job. Set only the target platform to `true`.

**`mqttTopics`** — Declares which MQTT topics your task may publish to and subscribe from. The broker enforces this list. If `KeeperLogger` is missing here, your process's publish calls will be denied even if the MQTT connection succeeds.

**`tasks`** — The array of steps to run. For most custom tools, one task is enough. Tasks run sequentially.

**`arguments`** — Supports variable substitution. `{KeeperApiBaseUrl}` is replaced by the agent with the local HTTPS API base URL (for example `https://127.0.0.1:6889`). Your binary parses this flag and uses it to call Plugin Settings at runtime. See the code example in the [Overview page](/keeperpam/endpoint-privilege-manager/integrations/overview.md).

**Schedule Options**

The job supports four scheduling modes. Only one mode may be active per job.

**Interval** — Runs every N minutes from when the scheduler starts:

```json
"schedule": { "intervalMinutes": 120 }
```

**Cron** — Standard five-field cron expression (minute, hour, day-of-month, month, day-of-week):

```json
"schedule": { "cronExpression": "0 3 * * *" }
```

**One-time** — Runs once at a specific UTC time:

```json
"schedule": { "runAt": "2025-06-01T02:00:00Z" }
```

**Calendar** — Runs at specific times on specific days of the week or month:

```json
"schedule": {
  "calendar": [
    { "time": "03:00", "daysOfWeek": ["Monday", "Wednesday", "Friday"] }
  ]
}
```

**Startup vs. Interval Timing**

A job with both a `Startup` event and an `intervalMinutes` schedule will run at startup (once, when the agent starts) and then again on the interval from that point forward. The interval timer runs independently — do not assume a fixed relationship between the Startup run and the first interval run without testing on your target agent version.

When the agent starts, Startup jobs are triggered after jobs are loaded and after KeeperLogger is ready to receive messages. The interval timer's first tick occurs after the configured number of minutes from when the scheduler initializes — not from when the Startup run completes.
{% endstep %}

{% step %}
**Deploy the Job**

**Using the Local HTTPS API**

The agent exposes a REST API on the loopback interface for managing jobs. The HTTPS listener runs on `Settings:KestrelHttpsPort` (commonly `6889`). All job management endpoints require **Admin-tier** authorization — your deployment team will provide the authentication method (typically mutual TLS with client certificates provisioned for your environment).

To create or update a job:

```powershell
Invoke-RestMethod -Method Post `
  -Uri "https://127.0.0.1:6889/api/Jobs" `
  -ContentType "application/json" `
  -Certificate $adminClientCert `
  -Body (Get-Content -Raw .\secrets-scanner.json)
```

```bash
curl -s -X POST https://127.0.0.1:6889/api/Jobs \
  --cert /path/to/client.pem \
  --key /path/to/client.key \
  --cacert /path/to/ca.pem \
  -H "Content-Type: application/json" \
  -d @secrets-scanner.json
```

The key job management endpoints:

<table data-header-hidden="false" data-header-sticky><thead><tr><th width="98.45452880859375">Method</th><th width="240.39398193359375">Path</th><th>Purpose</th></tr></thead><tbody><tr><td><code>POST</code></td><td><code>/api/Jobs</code></td><td>Create a job</td></tr><tr><td><code>PUT</code></td><td><code>/api/Jobs/{jobId}</code></td><td>Replace an existing job</td></tr><tr><td><code>DELETE</code></td><td><code>/api/Jobs/{jobId}</code></td><td>Remove a job</td></tr><tr><td><code>POST</code></td><td><code>/api/Jobs/validate</code></td><td>Validate JSON without saving</td></tr><tr><td><code>GET</code></td><td><code>/api/Jobs</code></td><td>List all jobs</td></tr><tr><td><code>GET</code></td><td><code>/api/Jobs/{jobId}</code></td><td>Get one job definition</td></tr><tr><td><code>POST</code></td><td><code>/api/Jobs/{jobId}/trigger</code></td><td>Trigger a run manually</td></tr></tbody></table>

For the full API reference including Plugin Settings endpoints and authorization details, see the [HTTP Reference](/keeperpam/endpoint-privilege-manager/integrations/http-reference-guide.md).

**Validation Requires Binaries to be Present**

`POST /api/Jobs` and `POST /api/Jobs/validate` run the job validator, which checks that your task's executable exists on disk at the time of the call. This means you must **deploy the binary before registering the job** via the API, or validate on a machine that already has the binary at the expected path.

If the job's `osFilter` excludes the current operating system, binary existence checks are skipped for that validation run. This lets you register a Linux-only job from a Windows host.

**Using a `JobUpdate` Policy**

In managed fleet environments, the typical deployment path is a `JobUpdate` policy delivered through the Keeper console. Your Keeper administrator handles this — you provide the job JSON, and they configure the policy envelope and push it to endpoints. Contact your administrator for the current policy schema and console steps. See also: [Create, Modify, or Delete Job](/keeperpam/endpoint-privilege-manager/policies/policy-examples/advanced-examples/policy-create-modify-or-delete-job.md)
{% endstep %}

{% step %}
**Connect to MQTT and Publish Logs**

Once your binary is deployed and the job is registered, you need to wire up MQTT so log messages flow through the agent's logging pipeline.

**Step 1: Declare MQTT Permissions on the Job**

Add `mqttTopics` to the job root object. The broker enforces this list — if a topic is missing, publishes to it will be denied even if the MQTT connection itself succeeds.

```json
"mqttTopics": {
  "allowedPublications": ["KeeperLogger"],
  "allowedSubscriptions": []
}
```

If your tool also publishes to a custom event topic (see [Job progress over MQTT](#optional-job-progress-over-mqtt)), add that topic to `allowedPublications` as well.

`allowedSubscriptions` is only needed if your tool subscribes to MQTT topics. Most job task binaries do not.

**Step 2: Read the Broker Address from Plugin Settings**

When the job defines `mqttTopics` with at least one entry, the agent sets two environment variables before starting your task:

<table data-header-hidden="false" data-header-sticky><thead><tr><th>Variable</th><th>Value</th></tr></thead><tbody><tr><td><code>KEEPER_JOB_ID</code></td><td>The <code>id</code> field from your job JSON</td></tr><tr><td><code>KEEPER_JOB_NAME</code></td><td>The <code>name</code> field from your job JSON</td></tr></tbody></table>

Your binary needs `KEEPER_JOB_ID` to form the MQTT client ID. It needs the broker address from Plugin Settings. Both happen at startup:

```python
import os
import argparse
import json
import ssl
import urllib.request

FALLBACK_HOST = "127.0.0.1"
FALLBACK_PORT = 8675

def load_broker_settings(keeper_api_base: str) -> tuple[str, int]:
    url = f"{keeper_api_base}/api/PluginSettings/KeeperPrivilegeManager"
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE  # self-signed on loopback; use CA bundle if provided
    try:
        with urllib.request.urlopen(url, context=ctx, timeout=10) as resp:
            settings = json.loads(resp.read().decode())
        return settings.get("broker.host", FALLBACK_HOST), int(settings.get("broker.port", FALLBACK_PORT))
    except Exception as exc:
        print(f"[WARNING] Plugin Settings unavailable ({exc}). Using defaults.")
        return FALLBACK_HOST, FALLBACK_PORT

parser = argparse.ArgumentParser()
parser.add_argument("--keeper-api-base", required=True)
args = parser.parse_args()

job_id   = os.environ.get("KEEPER_JOB_ID", "")
job_name = os.environ.get("KEEPER_JOB_NAME", "")
broker_host, broker_port = load_broker_settings(args.keeper_api_base)
```

A few things to remember here:

* **This call requires Plugin-tier auth.** It only succeeds from a process the agent launched. A manually started copy of your binary will get `403`.
* **The agent uses a self-signed TLS certificate on the loopback interface.** If your organization provides a CA bundle for the agent's certificate, configure your HTTP client to use it. Otherwise, disable certificate verification for loopback-only connections per your security policy.
* **Always fall back to defaults on failure, but log a warning.** A failed Plugin Settings call in production is a configuration problem operators need to see.

**Step 3: Form the MQTT Client ID**

The broker validates your client ID against the job it's associated with. For job tasks, the format is:

```
{KEEPER_JOB_ID}_{ExecutableToken}_{ProcessId}
```

Where:

* `{KEEPER_JOB_ID}` is the value from the environment variable the agent set
* `{ExecutableToken}` is a short, stable name for your binary (no path characters, no spaces, no underscores)
* `{ProcessId}` is the current OS process ID as a decimal integer

```python
import os

job_id = os.environ.get("KEEPER_JOB_ID", "unknown")
pid    = os.getpid()
client_id = f"{job_id}_SecretScanner_{pid}"
# Example result: "secrets-scanner_SecretScanner_48292"
```

**Do not use underscores in the job ID.** The broker splits the client ID on `_` to extract the job ID segment. If your job ID contains underscores — for example `secrets_scanner` — the broker will mis-parse it, fail to associate the connection with the right job, and publish permission checks will fail. Use hyphens: `secrets-scanner`.

**Step 4: Connect to the MQTT Broker**

Connect using TLS to the host and port you retrieved from Plugin Settings. The broker requires an encrypted connection — plain TCP is not accepted.

```python
import ssl
import paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code == 0:
        print("Connected to MQTT broker")
    else:
        print(f"MQTT connection failed: {reason_code}")

mqttc = mqtt.Client(
    mqtt.CallbackAPIVersion.VERSION2,
    client_id=client_id,
    protocol=mqtt.MQTTv5
)

# TLS configuration — use CA bundle if provided, otherwise trust loopback cert
tls_ctx = ssl.create_default_context()
tls_ctx.check_hostname = False
tls_ctx.verify_mode = ssl.CERT_NONE  # replace with CA bundle if available
mqttc.tls_set_context(tls_ctx)

mqttc.on_connect = on_connect
mqttc.connect(broker_host, broker_port, keepalive=60)
mqttc.loop_start()
```

**Note on timing:** If your binary connects to MQTT immediately on startup and the connection is refused, it is likely a race between your connection attempt and the agent's process registration completing. Add a short retry loop — one or two seconds — before treating the connection as failed.

Prefer MQTT v5 when your client library supports it. The agent's internal components use MQTT v5.

**Step 5: Publish Log Messages to KeeperLogger**

Publish to the topic `KeeperLogger` with QoS 1 and `retain: false`. Each publish must be a single JSON object in the following shape — property names are case-sensitive:

```json
{
  "Id": "b2e7f4a8-6c2d-4b1e-9f3a-8c1d2e3f4a5b",
  "Version": 1,
  "RespondToTopic": null,
  "MetaData": {
    "MessageType": 4,
    "LogLevel": 1,
    "Source": "SecretScanner",
    "Category": "SecretsScan",
    "Message": "Scan complete: 0 issues detected.",
    "CorrelationId": "",
    "Context": ""
  }
}
```

Field Reference:

<table data-header-hidden="false" data-header-sticky><thead><tr><th width="223.5333251953125">Field</th><th>Value</th></tr></thead><tbody><tr><td><code>Id</code></td><td>A new random UUID string for each message</td></tr><tr><td><code>Version</code></td><td><code>1</code></td></tr><tr><td><code>RespondToTopic</code></td><td><code>null</code></td></tr><tr><td><code>MetaData.MessageType</code></td><td><code>4</code> (or the string <code>"Log"</code>)</td></tr><tr><td><code>MetaData.LogLevel</code></td><td><code>0</code> Debug, <code>1</code> Info, <code>2</code> Warning, <code>3</code> Error, <code>4</code> Critical, <code>5</code> Verbose — or the string equivalents</td></tr><tr><td><code>MetaData.Source</code></td><td>Your tool's name, shown in logs</td></tr><tr><td><code>MetaData.Category</code></td><td>A short label for the phase or component</td></tr><tr><td><code>MetaData.Message</code></td><td>Human-readable text — must be non-empty. Do not include raw secret values; log counts and paths instead</td></tr><tr><td><code>MetaData.CorrelationId</code></td><td>Optional trace ID; use <code>""</code> if unused</td></tr><tr><td><code>MetaData.Context</code></td><td>Optional extra text; use <code>""</code> if unused</td></tr></tbody></table>

A complete publish example in Python:

```python
import uuid
import json

def publish_log(client, source: str, category: str, message: str, level: int = 1):
    payload = {
        "Id": str(uuid.uuid4()),
        "Version": 1,
        "RespondToTopic": None,
        "MetaData": {
            "MessageType": 4,
            "LogLevel": level,
            "Source": source,
            "Category": category,
            "Message": message,
            "CorrelationId": "",
            "Context": ""
        }
    }
    client.publish("KeeperLogger", json.dumps(payload), qos=1, retain=False)

# Usage
publish_log(mqttc, "SecretScanner", "SecretsScan", "Scan complete: 0 issues detected.")
publish_log(mqttc, "SecretScanner", "SecretsScan", "3 files could not be read.", level=2)
```

A few things to keep in mind:

* **`Message` must be non-empty.** The logger rejects empty messages.
* **Do not include raw secrets in log messages.** Log counts, file paths, and status — never the credential values themselves.
* **Avoid raw newlines inside `Message` and `Context`.** Replace them with spaces if you are logging scraped file content.
  {% endstep %}
  {% endstepper %}

## Optional: Job Progress Over MQTT

If you need downstream subscribers to observe job execution events — separate from the structured log messages going to `KeeperLogger` — you can publish to the job's event topic.

The default event topic pattern is `Jobs/{jobId}/events`. You can also set a custom topic with the `eventTopic` field on the job root:

```json
{
  "id": "secrets-scanner",
  "eventTopic": "Jobs/secrets-scanner/events",
  "mqttTopics": {
    "allowedPublications": [
      "KeeperLogger",
      "Jobs/secrets-scanner/events"
    ],
    "allowedSubscriptions": []
  }
}
```

**The event topic must also appear in `allowedPublications`.** If it is missing from that list, publish calls to it will be denied even though the connection succeeds. When you add an `eventTopic`, always verify that both `eventTopic` and `allowedPublications` contain the same topic string.

## Pre-Launch Checklist

Run through every item on a pilot endpoint before rolling out to your fleet.

<table data-header-hidden="false" data-header-sticky><thead><tr><th width="59.18182373046875">#</th><th>Check</th></tr></thead><tbody><tr><td>1</td><td>Binary exists at the resolved <code>command</code> / <code>executablePath</code> on the endpoint before <code>POST /api/Jobs</code> is called</td></tr><tr><td>2</td><td>Job <code>id</code> matches filename; no underscores in the <code>id</code></td></tr><tr><td>3</td><td><code>enabled</code> is <code>true</code></td></tr><tr><td>4</td><td><code>osFilter</code> matches the target platform</td></tr><tr><td>5</td><td><code>mqttTopics.allowedPublications</code> includes <code>KeeperLogger</code> (and the <code>eventTopic</code> if used)</td></tr><tr><td>6</td><td>Job appears in <code>GET /api/Jobs</code> after deployment</td></tr><tr><td>7</td><td><code>POST /api/Jobs/{jobId}/trigger</code> runs the task successfully (Admin auth required)</td></tr><tr><td>8</td><td>Running task receives <code>KEEPER_JOB_ID</code> and <code>KEEPER_JOB_NAME</code> environment variables</td></tr><tr><td>9</td><td><code>GET /api/PluginSettings/KeeperPrivilegeManager</code> returns <code>broker.host</code> and <code>broker.port</code> from inside the running task</td></tr><tr><td>10</td><td>MQTT connects with TLS and the correct <code>{jobId}_Token_{pid}</code> client ID format</td></tr><tr><td>11</td><td>Publish to <code>KeeperLogger</code> succeeds and log messages appear in the operator's log view</td></tr><tr><td>12</td><td>If using <code>eventTopic</code>: publish to that topic succeeds</td></tr><tr><td>13</td><td>Code signing: production binary is signed; thumbprint is in <code>AlternativeSignatures</code> if needed</td></tr></tbody></table>

## Troubleshooting

<table data-header-hidden="false" data-header-sticky><thead><tr><th>Symptom</th><th>Where to look</th></tr></thead><tbody><tr><td><code>POST /api/Jobs</code> returns <code>403</code></td><td>Admin auth — wrong certificate or the calling process is not admin-authorized</td></tr><tr><td><code>POST /api/Jobs</code> returns <code>400</code> with validation errors</td><td>Binary not found at the path on the validating host; deploy binary first</td></tr><tr><td>Job appears in <code>GET /api/Jobs</code> but does not run</td><td>Check <code>enabled: true</code>; check <code>osFilter</code> matches the endpoint OS</td></tr><tr><td>Hand-edited <code>Jobs/*.json</code> is reverted</td><td>Last Known Good is enabled — use the API or <code>JobUpdate</code> policy instead</td></tr><tr><td><code>GET /api/PluginSettings/...</code> returns <code>403</code> from inside the task</td><td>Process not registered as trusted; ensure it is started by the job runner, not manually</td></tr><tr><td>MQTT connection refused</td><td>Process not in launched-process registry; add retry logic on startup</td></tr><tr><td>MQTT connects but publish is denied</td><td>Topic missing from <code>mqttTopics.allowedPublications</code> in the job JSON</td></tr><tr><td>Client ID parsing errors / wrong job association</td><td>Underscores in job <code>id</code> — switch to hyphens</td></tr><tr><td>Log messages not appearing in operator view</td><td>KeeperLogger may not be running or subscribed; confirm with your administrator</td></tr></tbody></table>


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.keeper.io/keeperpam/endpoint-privilege-manager/integrations/custom-job-guide.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
