open-swe/CUSTOMIZATION.md
Adam Moussa 1f80500cf3
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
feat: Jira + Confluence integration (tools + triggers) (#182)
* feat(open-swe): add Jira tool plane (Phase 1)

Curated Jira Cloud REST v3 toolset for the agent, mirroring the Linear
tools:

- utils/jira.py: service-account REST client (Basic auth) with get/
  create/update issue, comments, list projects, trace comment; issue and
  comment bodies normalized to markdown.
- utils/adf.py: minimal ADF <-> markdown conversion (read paths convert
  Jira ADF to markdown; agent comments convert prose to ADF).
- tools/jira_{comment,get_issue,get_issue_comments,create_issue,
  update_issue,list_projects}.py wired into the tool registry and the
  main agent tool list.
- tests/test_jira_utils.py: ADF conversion + mocked-transport util tests.

Reads JIRA_BASE_URL / JIRA_SERVICE_EMAIL / JIRA_API_TOKEN; unset env
returns a clean error, so this is safe to land dark. Trigger plane,
prompt guidance, and config plumbing follow in Phase 2.

* feat(open-swe): add Confluence tool plane (Phase 3)

Curated Confluence Cloud REST toolset for the agent, mirroring the Jira
tools:

- utils/confluence.py: service-account REST client (Basic auth) with
  get/create/update page, add comment, CQL search. Page bodies are XHTML
  storage format (not ADF), with minimal storage<->text converters;
  update_page reads the current version and bumps it, as Confluence
  requires.
- tools/confluence_{get_page,create_page,update_page,comment,search}.py
  registered in the tool registry.
- tests/test_confluence_utils.py: converter + mocked-transport tests
  including the version-bump path.

Reads CONFLUENCE_BASE_URL / CONFLUENCE_EMAIL / CONFLUENCE_API_TOKEN;
unset env returns a clean error. Activation in the agent tool list lands
with the Phase 2 server.py wiring.

* feat(open-swe): add Jira trigger plane (Phase 2)

Make an @openswe comment on a Jira issue spawn an agent run, mirroring
the Linear trigger plane:

- webhooks/jira.py: process_jira_issue clones process_linear_issue —
  deterministic thread id, full-issue fetch, actor accountId->email
  attribution feeding resolve_login_from_email_async (PRs open as the
  human), multimodal image handling, source="jira" + jira_issue config.
- webapp.py: POST/GET /webhooks/jira, verify_jira_secret (constant-time
  X-Automation-Webhook-Token check, fails closed), repo-resolution
  cascade, get_repo_config_from_jira_mapping.
- utils/jira_project_repo_map.py: JIRA_PROJECT_TO_REPO (placeholder
  entry — real project->repo mappings still needed).
- utils/jira.py: get_user_email (accountId -> email) for attribution.
- completion.py: source=="jira" failure-reply branch.
- prompt.py: Jira-triggered notify guidance + Refs:/branch key from
  {jira_project_key}-{jira_issue_number}.
- server.py: read jira_issue config + pass jira key to the system
  prompt; also activates the Phase 3 Confluence tools in the agent list.

Jira Automation lacks native webhook HMAC signing, so trust is a shared
secret header (decision D2); replay protection is weaker than Linear's
HMAC+timestamp. /sh-security-review + an Atlassian IP allowlist are the
outstanding gate/hardening before push.

* fix(open-swe): harden Jira webhook trust (sh-security-review)

Resolves findings from the Phase 2 security review (detector fan-out +
proof-or-kill verifier). The unsigned Jira Automation webhook body was
trusted for identity, comment content, repo routing, and issue
existence; a JIRA_WEBHOOK_SECRET holder could forge those fields.

- Corroborate against the real Jira record: the webhook body is now only
  a pointer (issue_key + required comment_id). The triggering comment's
  author and text are re-fetched server-side via get_comment/fetch_jira_
  comment, and identity, the @openswe check, prompt text, and project
  key are derived from that authoritative record — never payload author/
  body fields. An uncorroborated comment is rejected. (closes the
  account-id impersonation, unsigned-body prompt injection, and
  fabricated-issue findings)
- Validate issue_key against the Jira key format and percent-encode all
  untrusted path segments (_seg) so a crafted key can't traverse to a
  different Jira REST endpoint or inject query params. (closes the path-
  traversal / query-injection findings)
- Route source=="jira" through the bot-token-default / author_prs_as_
  user opt-in path in resolve_github_token, matching Linear, instead of
  unconditionally resolving a per-user OAuth token from a payload email.
- Gate attribution on an active user mapping (is_login_mapped) so a
  pending/unconfirmed mapping can't drive PR authorship.

Adds regression tests: server-corroboration wins over payload, malformed
issue_key rejected, uncorroborated comment rejected, path-segment
encoding, project-key derivation, active-mapping gate.

Remaining (non-blocking, deployment/hardening): set ALLOWED_GITHUB_ORGS/
REPOS so the shared allowlist isn't fail-open; consider HMAC-over-body +
timestamp on the Automation payload to close the residual replay gap.

* harden(open-swe): opt-in Jira webhook replay/IP + fail-closed allowlist

Folds the two deployment-hardening items from the Phase 2 security review
into code (all opt-in / default-off, so existing and upstream deployments
are unaffected):

- JIRA_WEBHOOK_REQUIRE_SIGNATURE: when set, the Automation payload must
  carry X-Openswe-Signature (hex HMAC-SHA256 of the raw body keyed by
  JIRA_WEBHOOK_SECRET) plus a fresh timestamp, verified by
  verify_jira_signature / _jira_timestamp_is_fresh (mirrors the Linear
  HMAC+freshness model). Closes the static-token model's replay/forgery
  gap when enabled.
- JIRA_WEBHOOK_IP_ALLOWLIST: optional CIDR allowlist on the webhook's
  direct client IP (verify_jira_source_ip). Documented as direct-peer
  only; behind a proxy/LB, allowlist Atlassian's ranges at that layer.
- REQUIRE_REPO_ALLOWLIST: makes an empty ALLOWED_GITHUB_ORGS/REPOS fail
  CLOSED instead of the back-compat allow-all, plus a startup fail-open
  warning. Applies to all channels for consistency.

Documents all new vars (and a Jira section) in .env.example. Adds tests
for signature on/off + valid/missing/wrong/stale, IP allow/deny/off, and
the fail-closed allowlist.

* feat(open-swe): Confluence Atlassian Connect trigger (Phase 4)

Adds the @openswe-on-a-Confluence-comment trigger via a private Atlassian
Connect app. Designed and adversarially verified with the ultracode
workflow (3 divergent Opus designs + judge; 3 proof-or-kill Opus
skeptics on the implemented crypto).

- utils/atlassian_connect.py: hand-rolled qsh (pinned to Atlassian's
  official test vector), PyJWT HS256 webhook verifier with alg-pinning,
  issuer binding, and qsh-verified-last ordering; RS256 signed-install
  lifecycle verifier against Atlassian's published keys; installation
  store keyed by clientKey with the sharedSecret encrypted at rest
  (TOKEN_ENCRYPTION_KEY / Fernet). No new dependency (PyJWT already pinned).
- webhooks/confluence.py: install/uninstall lifecycle + comment handler.
  The JWT-signed webhook body is only a pointer; the comment's real
  author/text/container are re-fetched server-side via the Basic-auth
  service account (Phase-2 corroboration lesson), with active-only login
  attribution and the repo allowlist.
- utils/confluence.py: get_comment / get_user_email (path-encoded).
- webapp.py: GET /connect/atlassian-connect.json (served dynamically),
  POST /connect/{installed,uninstalled,webhook/comment-created}, the
  space->repo resolver, thread-id, and fetch helpers.
- completion.py: source=="confluence" failure-reply branch.

Security: the sh-security-review verify pass confirmed one HIGH — the
symmetric signed-install=false first-install was trust-on-first-use gated
only by the public Confluence hostname (webhook-auth bypass). Fixed by
switching to signed-install=true + RS256 verification of lifecycle
callbacks, which cryptographically authenticates the first install. All
other attack lenses (forgery/replay/alg-confusion/overwrite/uninstall
DoS/corroboration/injection) were defeated; residuals are deployment
config (REQUIRE_REPO_ALLOWLIST) or accepted-by-design (qsh cannot cover
bodies; comment-trigger prompt injection, shared with all sources).

New env (documented in .env.example): CONFLUENCE_BASE_URL/EMAIL/API_TOKEN,
CONNECT_BASE_URL, CONNECT_EXPECTED_BASE_URL (optional). Install secrets
require the durable Postgres LangGraph store in prod.

Outstanding before push: /sh-security-review on the real diff and the
GPT-4.1 cross-family review (auth boundary); README/CLAUDE.md + memory.

* docs(open-swe): Phase 5 — Confluence prompt guidance + architecture docs

- prompt.py: Confluence-triggered runs notify via confluence_comment on
  the triggering page; add Confluence to the shared-base source list.
- CLAUDE.md: document the Jira + Confluence tool planes and the Atlassian
  triggers (Jira Automation shared-secret webhook; Confluence Connect app
  with HS256 webhook + qsh and RS256 signed-install lifecycle), plus the
  server-side corroboration + encrypted install store.

Phase 5 also verified the trigger surface end-to-end against a running
uvicorn app (descriptor served; /connect/* and /webhooks/jira fail closed
without valid auth) and recorded the integration in project memory.

* fix(open-swe): resolve /sh-security-review findings on the Atlassian surface

Formal sh-security-review (detector fan-out + verifier) over the Phase-4
Connect surface (esp. the new RS256 signed-install code, unseen by the
earlier adversarial verify) and the Phase-2 opt-in hardening.

CRITICAL — cross-tenant install (origin validation, CWE-346): signed-
install proves the caller is *an* Atlassian tenant, not *ours*, and the
descriptor is served publicly, so any attacker could install the app on
their own Confluence site and drive agent runs against our allowlisted
repos. The baseUrl body field is attacker-controlled and cannot bind the
tenant; only the signature-verified clientKey (JWT iss) can. Added a
MANDATORY, fail-closed CONNECT_EXPECTED_CLIENT_KEYS allowlist checked in
process_install after signature+iss verification.

HIGH — cross-tenant thread-id collision (CWE-330/863): Confluence comment
ids are per-instance, so generate_thread_id_from_confluence_comment now
salts the hash with the verified clientKey (plumbed from the webhook JWT
iss) to prevent thread hijack across tenants.

HIGH/MEDIUM — path/query injection (CWE-22/88): get_page and update_page
interpolated page_id into the REST path unencoded (update_page on a
mutating PUT with no params= backstop). Now _seg()-encoded, matching the
rest of the module.

MEDIUM — self-trigger loop (CWE-405): process_confluence_comment had no
bot-authorship early-out. Added an optional CONFLUENCE_BOT_ACCOUNT_ID
guard mirroring the Linear botActor / Jira comment_author_is_bot checks.

LOW — corrected the CONNECT_EXPECTED_BASE_URL comment to document it as
opt-in defense-in-depth (the clientKey allowlist is the real gate).

Verified clean by the detectors: RS256/HS256 alg-pinning, aud/iss/exp,
kid-fetch SSRF (host-pinned + quote-encoded), at-rest secret encryption,
constant-time comparisons, and the Phase-2 hardening. New regression
tests for each fix; full suite green (1602).

* harden(open-swe): GPT-4.1 cross-family review follow-ups

Cross-family review (GPT-4.1 via orchestrator cross_reviewer) found no
critical/high issues and confirmed the auth boundary is fail-closed and
correct. Two low-cost defense-in-depth items applied:

- Validate the signed-install JWT 'kid' against a strict charset before
  the public-key fetch, so a malformed kid fails fast with no network
  call (on top of the existing fixed host + percent-encoding).
- Make JWT nbf verification explicit (verify_nbf) on both the RS256
  lifecycle and HS256 webhook decodes.

Other suggestions triaged as already-handled (aud cross-app replay is
blocked by the per-tenant iss->secret lookup; documented static-token/IP/
baseUrl tradeoffs; qsh pinned to Atlassian's vector) or ops/infra
(Fernet rotation via MultiFernet; rate limiting at the gateway).

* docs(open-swe): document Jira + Confluence in installation & customization guides

- INSTALLATION.md §5: add Jira (Automation-rule webhook + shared secret,
  service account, JIRA_PROJECT_TO_REPO) and Confluence (Atlassian Connect
  app install, CONNECT_EXPECTED_CLIENT_KEYS bootstrap, durable-store note,
  CONFLUENCE_SPACE_TO_REPO) trigger setup; §6: add the new env vars +
  REQUIRE_REPO_ALLOWLIST.
- CUSTOMIZATION.md: jira_*/confluence_* in the tools table; repo-extraction
  note covers all four sources.
- AGENTS.md: match CLAUDE.md (triggers, webhooks, tool list, auth).
- README.md: invocation section, tools table, and overview line.
2026-07-13 19:45:54 -04:00

520 lines
22 KiB
Markdown

# Customization Guide
Open SWE is designed to be forked and customized for your org. The core agent is assembled in a single function — `get_agent()` in `agent/server.py` — where you can swap out the sandbox, model, tools, and triggers.
```python
# agent/server.py — the key lines
model_id = os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID)
model_kwargs = {"max_tokens": DEFAULT_LLM_MAX_TOKENS}
if model_id == DEFAULT_LLM_MODEL_ID:
model_kwargs["reasoning"] = DEFAULT_LLM_REASONING
return create_deep_agent(
model=make_model(model_id, **model_kwargs),
system_prompt=construct_system_prompt(...),
tools=[http_request, fetch_url, linear_comment, slack_thread_reply],
backend=sandbox_backend,
middleware=[
ToolErrorMiddleware(),
check_message_queue_before_model,
ensure_no_empty_msg,
notify_step_limit_reached,
],
)
```
---
## 1. Sandbox
By default, Open SWE runs each task in a [LangSmith cloud sandbox](https://docs.smith.langchain.com/) — an isolated Linux environment where the agent clones the repo and executes commands. Sandbox creation and connection is handled in `agent/integrations/langsmith.py`.
### Using a custom sandbox snapshot
Build a snapshot in LangSmith (UI or `SandboxClient.create_snapshot`) from your Docker image and point Open SWE at its UUID:
```bash
DEFAULT_SANDBOX_SNAPSHOT_ID="<snapshot-uuid>" # Required
DEFAULT_SANDBOX_SNAPSHOT_FS_CAPACITY_BYTES="34359738368" # Optional, default 32 GiB
DEFAULT_SANDBOX_VCPUS="4" # Optional, default 4
DEFAULT_SANDBOX_MEM_BYTES="16106127360" # Optional, default 15 GiB
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="7200" # Optional, default 7200 (2 h); 0 disables
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="86400" # Optional, default 86400 (24 h); 0 disables
REPO_SNAPSHOT_BASE_IMAGE="<registry>/<open-swe-sandbox-image>" # Optional; required for admin-generated repo snapshot templates
```
This is useful for pre-installing languages, frameworks, or internal tools that your repos depend on — reducing setup time per agent run. The default snapshot includes the GitHub CLI; agents invoke it as `GH_TOKEN=dummy gh <command>` and rely on the LangSmith proxy for the real credentials.
`REPO_SNAPSHOT_BASE_IMAGE` should point to the published Docker image used to create your default Open SWE sandbox snapshot (typically the image built from this repository's `Dockerfile`). The admin **Repository Snapshots** page uses it as the base image when generating per-repo Dockerfile templates. If it is not configured, template generation fails closed instead of suggesting a bare image that would be missing Open SWE's required sandbox tools.
For LangSmith sandboxes, Open SWE configures two GitHub proxy rules whenever a sandbox is created or reattached to a run:
- `github.com` / `*.github.com` receive Basic auth for git-over-HTTPS operations.
- `api.github.com` receives Bearer auth for `gh` and REST API operations.
The proxy token is minted at runtime from the GitHub App installation credentials. Do not store GitHub access tokens as deployment environment variables.
### Using a different sandbox provider
Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider has a corresponding integration file in `agent/integrations/` and a factory function registered in `agent/utils/sandbox.py`:
| `SANDBOX_TYPE` | Integration file | Required env vars |
|---|---|---|
| `langsmith` (default) | `agent/integrations/langsmith.py` | `LANGSMITH_API_KEY_PROD`, `SANDBOX_TYPE="langsmith"` |
| `daytona` | `agent/integrations/daytona.py` | `DAYTONA_API_KEY`, `SANDBOX_TYPE="daytona"`, optional `DAYTONA_SANDBOX_SNAPSHOT` |
| `runloop` | `agent/integrations/runloop.py` | `RUNLOOP_API_KEY`, `SANDBOX_TYPE="runloop"` |
| `modal` | `agent/integrations/modal.py` | Modal credentials, `SANDBOX_TYPE="modal"` |
| `local` | `agent/integrations/local.py` | None (no isolation — development only), `SANDBOX_TYPE="local"` |
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
### Adding a new sandbox provider
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:
```python
def create_my_provider_sandbox(sandbox_id: str | None = None):
"""Create or reconnect to a sandbox.
Args:
sandbox_id: Optional existing sandbox ID to reconnect to.
If None, creates a new sandbox.
Returns:
An object implementing SandboxBackendProtocol.
"""
...
```
2. **Register it** in `agent/utils/sandbox.py` by importing your factory and adding it to `SANDBOX_FACTORIES`:
```python
from agent.integrations.my_provider import create_my_provider_sandbox
SANDBOX_FACTORIES = {
...
"my_provider": create_my_provider_sandbox,
}
```
The factory must return an object implementing `SandboxBackendProtocol` from `deepagents`. See the existing integration files for reference.
### Building a custom sandbox provider
If none of the built-in providers fit, you can build your own. The agent accepts any backend that implements `SandboxBackendProtocol` from `deepagents`. The protocol requires:
- **File operations**: `ls()`, `read()`, `write()`, `edit()`, `glob()`, `grep()`
- **Shell execution**: `execute(command, timeout=None) -> ExecuteResponse`
- **Identity**: `id` property returning a unique sandbox identifier
The easiest approach is to extend `BaseSandbox` from `deepagents.backends.sandbox` — it implements all file operations by delegating to `execute()`, so you only need to implement the shell execution layer:
```python
from deepagents.backends.sandbox import BaseSandbox
from deepagents.backends.protocol import ExecuteResponse
class MySandbox(BaseSandbox):
def __init__(self, connection):
self._conn = connection
@property
def id(self) -> str:
return self._conn.id
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
result = self._conn.run(command, timeout=timeout or 300)
return ExecuteResponse(
output=result.stdout + result.stderr,
exit_code=result.exit_code,
truncated=False,
)
```
See `deepagents.backends.LangSmithSandbox` and `agent/integrations/langsmith.py` for a full reference implementation.
---
## 2. Model
The model is configured in the `get_agent()` function in `agent/server.py`. By default it uses `openai:gpt-5.5` with medium reasoning effort, but you can override the model with the `LLM_MODEL_ID` environment variable:
```bash
# Set the model via environment variable (uses provider:model format)
LLM_MODEL_ID="anthropic:claude-sonnet-5"
```
If `LLM_MODEL_ID` is not set, the default model (`openai:gpt-5.5`) is used.
`max_tokens` is a maximum completion/output token budget, not the model's total context window. For OpenAI reasoning models, this budget can include both internal reasoning tokens and final response tokens.
### Switching models
Use the `provider:model` format:
```python
# Anthropic
model=make_model("anthropic:claude-sonnet-5", temperature=0, max_tokens=16_000)
# OpenAI (uses Responses API by default)
model=make_model("openai:gpt-5.5", max_tokens=128_000, reasoning={"effort": "medium"})
# Google
model=make_model("google_genai:gemini-2.5-pro", temperature=0, max_tokens=16_000)
```
The `make_model()` helper in `agent/utils/model.py` wraps `langchain.chat_models.init_chat_model`. For OpenAI models, it automatically enables the Responses API. For full control, pass a pre-configured model instance directly:
```python
from langchain_anthropic import ChatAnthropic
model = ChatAnthropic(model_name="claude-sonnet-5", temperature=0, max_tokens=16_000)
return create_deep_agent(
model=model,
...
)
```
### Using different models per context
You can route to different models based on task complexity, repo, or trigger source:
```python
async def get_agent(config: RunnableConfig) -> Pregel:
source = config["configurable"].get("source")
if source == "slack":
# Faster model for Slack Q&A
model = make_model("anthropic:claude-sonnet-5", temperature=0, max_tokens=16_000)
else:
# Full model for code changes from Linear
model = make_model("openai:gpt-5.5", max_tokens=128_000, reasoning={"effort": "medium"})
return create_deep_agent(model=model, ...)
```
### Routing through the LangSmith LLM Gateway
Model calls can be proxied through the [LangSmith LLM Gateway](https://docs.langchain.com/langsmith/llm-gateway) (private beta) instead of hitting providers directly. The gateway authenticates with a **LangSmith API key** that has the `gateway:invoke` permission and resolves the real provider key from workspace Provider Secrets, so no provider API keys are needed at runtime — and it adds central spend limits, PII/secrets redaction, and tracing. Your org must have the gateway enabled with Provider Secrets configured.
Routing is opt-in and off by default. Enable it either way:
| Env var | Default | Purpose |
|---|---|---|
| `LANGSMITH_GATEWAY_ENABLED` | `false` | Deployment-level default for gateway routing. |
| `LANGSMITH_GATEWAY_API_KEY` | unset | Optional dedicated LangSmith key for Gateway calls. Prefer this in LangGraph Cloud if the platform-provided `LANGSMITH_API_KEY` lacks `gateway:invoke`. Falls back to `LANGSMITH_API_KEY_PROD`, then `LANGSMITH_API_KEY`. |
| `LANGSMITH_GATEWAY_BASE_URL` | `https://gateway.smith.langchain.com` | Override for a regional or self-hosted gateway host. |
| `LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES` | `true` | Use the OpenAI Responses API through the gateway. Set to `false` only to force Chat Completions for OpenAI models. |
The admin panel (**Admin → LLM Gateway**) exposes a per-workspace toggle stored in team settings; when set it overrides the `LANGSMITH_GATEWAY_ENABLED` env default (a `None`/unset team value inherits the env default).
Routing is applied centrally in `make_model` (`agent/utils/model.py`), which resolves the effective on/off and delegates URL/key wiring to `agent/utils/gateway.py`. **OpenAI, Anthropic, Fireworks, and Google Gemini** are routed (their LangChain integrations accept `base_url` + `api_key`); Google Vertex (service-account auth) and any other provider call the provider directly with a logged warning.
**Caveat — OpenAI endpoint:** open-swe uses the OpenAI Responses API by default because OpenAI reasoning models with function tools reject `reasoning_effort` on Chat Completions. Direct OpenAI calls use a `wss://` base URL; gateway-routed OpenAI uses the HTTPS gateway base URL with Responses enabled. Set `LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES=false` only if you need to force Chat Completions. Anthropic and Fireworks are unaffected.
---
## 3. Tools
Open SWE ships with a small set of custom tools on top of the built-in Deep Agents tools (file operations, shell execution, subagents, todos). GitHub operations are handled by `GH_TOKEN=dummy gh` inside the sandbox.
| Tool | File | Purpose |
|---|---|---|
| `fetch_url` | `agent/tools/fetch_url.py` | Fetch web pages as markdown |
| `http_request` | `agent/tools/http_request.py` | HTTP API calls |
| `linear_comment` | `agent/tools/linear_comment.py` | Post comments on Linear tickets |
| `jira_comment`, `jira_get_issue`, … | `agent/tools/jira_*.py` | Read/comment/create/update Jira issues (`agent/utils/jira.py`) |
| `confluence_get_page`, `confluence_update_page`, … | `agent/tools/confluence_*.py` | Read/write Confluence pages + comments (`agent/utils/confluence.py`) |
| `slack_thread_reply` | `agent/tools/slack_thread_reply.py` | Reply in Slack threads |
### Adding a tool
Create a new file in `agent/tools/`, define a function, and add it to the tools list.
**Example — adding a Datadog search tool:**
```python
# agent/tools/datadog_search.py
import requests
from typing import Any
def datadog_search(query: str, time_range: str = "1h") -> dict[str, Any]:
"""Search Datadog logs for debugging context.
Args:
query: Datadog log query string
time_range: Time range to search (e.g. "1h", "24h", "7d")
Returns:
Dictionary with matching log entries
"""
# Your Datadog API integration here
...
```
Then register it in `agent/server.py`:
```python
from .tools import fetch_url, http_request, linear_comment, slack_thread_reply
from .tools.datadog_search import datadog_search
return create_deep_agent(
...
tools=[
http_request, fetch_url,
linear_comment, slack_thread_reply,
datadog_search, # new tool
],
...
)
```
The agent will automatically see the tool's name, docstring, and parameter types — the docstring serves as the tool description, so write it clearly.
### Removing tools
If you only use Linear (not Slack), remove `slack_thread_reply` from the tools list and vice versa. If you don't need web fetching, remove `fetch_url`.
### Conditional tools
You can vary the toolset based on the trigger source:
```python
base_tools = [http_request, fetch_url]
source = config["configurable"].get("source")
if source == "linear":
tools = [*base_tools, linear_comment]
elif source == "slack":
tools = [*base_tools, slack_thread_reply]
else:
tools = [*base_tools, linear_comment, slack_thread_reply]
return create_deep_agent(tools=tools, ...)
```
---
## 4. Triggers
Open SWE supports three invocation surfaces: Linear, Slack, and GitHub. Each is implemented as a webhook endpoint in `agent/webapp.py`. You can add, remove, or modify triggers independently.
### Removing a trigger
If you don't use Linear, simply don't configure the Linear webhook and remove the env vars. Same for Slack. The webhook endpoints still exist but won't receive events.
To fully remove a trigger's code, delete the corresponding endpoint from `agent/webapp.py`:
- **Linear**: `linear_webhook()` and `process_linear_issue()`
- **Slack**: `slack_webhook()` and `process_slack_mention()`
### Default repository
Set the default GitHub org and repo used across all triggers (Slack, Linear, GitHub) when no repo is specified:
```bash
DEFAULT_REPO_OWNER="my-org" # Default GitHub org (used everywhere)
DEFAULT_REPO_NAME="my-repo" # Default GitHub repo (used everywhere)
```
These are used as the fallback when:
- A Slack message doesn't specify a repo (and no thread metadata exists)
- A Linear issue's team/project isn't in the `LINEAR_TEAM_TO_REPO` mapping
- A user writes `repo:name` without an org prefix — the org defaults to `DEFAULT_REPO_OWNER`
### Repository extraction from messages
Slack, Linear, Jira, and Confluence all support specifying a target repo directly in the message or comment text. The shared utility `extract_repo_from_text()` in `agent/utils/repo.py` handles parsing these formats:
- `repo:owner/name` — explicit org and repo
- `repo owner/name` — space syntax (same result)
- `repo:name` — repo name only; the org defaults to `DEFAULT_REPO_OWNER`
- `https://github.com/owner/name` — GitHub URL
### Customizing Linear routing
The `LINEAR_TEAM_TO_REPO` dict in `agent/utils/linear_team_repo_map.py` maps Linear teams and projects to GitHub repos:
```python
LINEAR_TEAM_TO_REPO = {
"Engineering": {
"projects": {
"backend": {"owner": "my-org", "name": "backend"},
"frontend": {"owner": "my-org", "name": "frontend"},
},
"default": {"owner": "my-org", "name": "monorepo"},
},
}
```
Users can also override the team/project mapping on a per-comment basis by including `repo:owner/name` in their `@openswe` comment. This takes priority over the mapping — the mapping is used as a fallback when no repo is specified in the comment. If the team/project isn't found in the mapping either, `DEFAULT_REPO_OWNER`/`DEFAULT_REPO_NAME` is used.
### Customizing Slack routing
Slack repo resolution (`get_slack_repo_config` in `agent/webapp.py`) checks, in order:
1. Repo carried over from the existing Slack thread's metadata.
2. A `repo:owner/name` (or GitHub URL) token in the channel's **topic or purpose** (its "description"). This lets a channel be pinned to a repo without anyone repeating it per-message.
3. The triggering user's dashboard `default_repo`.
4. The team default repo.
5. `SLACK_REPO_OWNER`/`SLACK_REPO_NAME`, falling back to `DEFAULT_REPO_OWNER`/`DEFAULT_REPO_NAME`.
Users can still override per-message with `repo:owner/name` syntax in their Slack message (this is read from the message text by the agent). A shorthand `repo:name` (without the org) is also supported — the org defaults to `DEFAULT_REPO_OWNER`.
Reading the channel topic/purpose requires the bot's Slack token to have the `channels:read` (and `groups:read` for private channels) scope so `conversations.info` succeeds.
### Adding a new trigger
To add a new invocation surface (e.g. Jira, Discord, a custom API):
1. **Add a webhook endpoint** in `agent/webapp.py`:
```python
@app.post("/webhooks/my-trigger")
async def my_trigger_webhook(request: Request, background_tasks: BackgroundTasks):
# Parse the incoming event
payload = await request.json()
# Extract task description and repo info
task_description = payload["description"]
repo_config = {"owner": "my-org", "name": "my-repo"}
# Create a LangGraph run
background_tasks.add_task(process_my_trigger, task_description, repo_config)
return {"status": "accepted"}
```
2. **Create a processing function** that builds the prompt and starts an agent run:
```python
async def process_my_trigger(task_description: str, repo_config: dict):
thread_id = generate_deterministic_id(task_description)
langgraph_client = get_client(url=LANGGRAPH_URL)
await langgraph_client.runs.create(
thread_id,
"agent",
input={"messages": [{"role": "user", "content": task_description}]},
config={"configurable": {
"repo": repo_config,
"source": "my-trigger",
"user_email": "user@example.com",
}},
if_not_exists="create",
)
```
3. **Add a communication tool** (optional) so the agent can report back:
```python
# agent/tools/my_trigger_reply.py
def my_trigger_reply(message: str) -> dict:
"""Post a reply to the triggering service."""
# Your API call here
...
```
The key fields in `config.configurable` are:
- `repo`: `{"owner": "...", "name": "..."}` — which GitHub repo to work on
- `source`: string identifying the trigger (used for auth routing and communication)
- `user_email`: the triggering user's email (for GitHub OAuth resolution)
---
## 5. System prompt
The system prompt is assembled in `agent/prompt.py` from modular sections. You can customize behavior by editing individual sections:
| Section | What it controls |
|---|---|
| `WORKING_ENV_SECTION` | Sandbox paths and execution constraints |
| `TASK_EXECUTION_SECTION` | Workflow steps (understand → implement → verify → submit) |
| `CODING_STANDARDS_SECTION` | Code style, testing, and quality rules |
| `COMMIT_PR_SECTION` | PR title/body format and commit conventions |
| `CODE_REVIEW_GUIDELINES_SECTION` | How the agent reviews code changes |
| `COMMUNICATION_SECTION` | Formatting and messaging guidelines |
### Default prompt file
Open SWE supports a `default_prompt.md` file for org-level instructions that apply to **every** agent run, regardless of which repository is being worked on. This is the recommended way to set default repository preferences, org conventions, and shared guidelines.
The file is loaded at agent startup and injected into the system prompt between the task overview and repository setup sections.
**Location:** [`default_prompt.md`](./default_prompt.md) in the project root.
**Override:** Set the `DEFAULT_PROMPT_PATH` environment variable to use a different file:
```bash
DEFAULT_PROMPT_PATH="/path/to/my-org-prompt.md"
```
**Format:** Write plain markdown. The content is injected as-is under a `### Custom Instructions` heading in the system prompt. Example:
```markdown
# Default Prompt
## Default Repository
When no repository is specified, work on the **my-app** repository under **my-org**.
## Organization Conventions
- Use conventional commits: feat:, fix:, chore:
- Always tag the requesting user when work is complete
```
**Loading order:** Default prompt → System prompt sections → AGENTS.md (per-repo). If the file is missing or empty, it is silently skipped — no error is raised.
**When to use `default_prompt.md` vs `AGENTS.md`:**
| | `default_prompt.md` | `AGENTS.md` |
|---|---|---|
| Scope | All tasks, all repos | Single repository |
| Location | Open SWE project root | Target repo root |
| Use for | Default repo, org conventions | Repo-specific coding standards |
### Using AGENTS.md
Drop an `AGENTS.md` file in the root of any repository to add repo-specific instructions. The agent reads it from the sandbox at startup and appends it to the system prompt. This is the easiest way to encode conventions per-repo without modifying Open SWE's code.
---
## 6. Middleware
Middleware hooks run around the agent loop. Open SWE includes:
| Middleware | Type | Purpose |
|---|---|---|
| `ToolErrorMiddleware` | Tool error handler | Catches and formats tool errors |
| `check_message_queue_before_model` | Before model | Injects follow-up messages that arrived mid-run |
| `ensure_no_empty_msg` | After model | Re-injects a tool call when the model stops without one, so runs don't end prematurely |
| `notify_step_limit_reached` | After agent | Posts a Slack reply when the agent hits the model-call limit |
There is intentionally no after-agent middleware that opens a PR for the agent. The agent is responsible for committing, pushing, opening/updating the draft PR, and replying in the source channel. If you want a deterministic backstop for your fork, add an `@after_agent` hook here.
Add custom middleware by appending to the middleware list in `get_agent()`. See the [LangChain middleware docs](https://python.langchain.com/docs/concepts/agents/#middleware) for the `@before_model` and `@after_agent` decorators.
**Example — adding a CI check after agent completion:**
```python
from langchain.agents.middleware import AgentState, after_agent
from langgraph.runtime import Runtime
@after_agent
async def run_ci_check(state: AgentState, runtime: Runtime):
"""Run CI checks after the agent finishes."""
# Trigger your CI pipeline here
...
```
Then add it to the middleware list:
```python
middleware=[
ToolErrorMiddleware(),
check_message_queue_before_model,
ensure_no_empty_msg,
notify_step_limit_reached,
run_ci_check, # new middleware
],
```