LaCrew docs

Connectors

A crew's work happens somewhere else. The dev crew's PRs are on GitHub, the content crew's drafts are in a CMS, the trading desk's pools are behind an RPC. Flows could only call the nine lacrew_* tools, so a crew could reason about text handed to it and gate a spend — and stop at the edge of the thing it exists to do.

A connector is an HTTP surface the operator registers with the orchestrator. Flows call its routes by name (github.get_pull_request), and everything else about the call is the operator's decision, not the flow's.

Start from a preset

Some connectors ship. A preset is the definition written out once and tested — base URL, paths, methods, and the arg allowlist for each route — so registering one is a decision rather than a transcription. See what ships:

lacrew connectors list
lacrew connectors show github

A preset never carries a credential, and it will not guess a write's policyTarget — that address only exists once the crew is stood up. Nor will it guess where a push may land. Bind what applies and emit the config:

lacrew connectors config github --policy-target merge_pull_request=0xMERGE_AUTHORITY

Registering only what a crew needs is the point: --omit leaves a route out entirely, and a read-only GitHub connector needs no address at all. Where several routes are one authority, they bind under one name — connectors show prints which, and says what it admits.

Credentials: prefer an App to a personal token

A preset can declare more than one way to authenticate, listed best-posture first. The first is what you get if you do not choose.

For GitHub that is a GitHub App installation, and the difference is not stylistic:

Personal access tokenApp installation
Reachwhatever its owner can reachonly the repos the App was installed on
Attributionevery crew action is a person'sthe App's own identity in GitHub's audit log
Revocationtakes away that person's access toouninstall, nobody else affected

An App credential is not a static string. You hold an app id and an RSA private key; the API wants an installation token that expires hourly. The registry does that exchange itself — it signs a short-lived RS256 JWT as the app, trades it at /app/installations/{id}/access_tokens, caches the result until five minutes before expiry, and re-mints once if a call comes back 401. The private key never leaves the process, and the installation token is never logged, never audited, and never returned to a flow.

GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY="$(cat lacrew-crew.private-key.pem)"   # literal \n also accepted
GITHUB_APP_INSTALLATION_ID=48213991

Reach for the token mode when you are trying something out, or when an App is more setup than the job deserves:

lacrew connectors config github --auth token \
  --policy-target merge_pull_request=0xMERGE_AUTHORITY

Ask for the merge route without an address and the command refuses rather than printing config that would stop the orchestrator at boot. A crew that only reads should leave the write out entirely — --omit merge_pull_request needs no address, and the narrowest connector that does the job is the one to register.

LACREW_CONNECTORS also takes the reference directly, so the definition stays in one place:

[{ "preset": "github", "policyTargets": { "merge_pull_request": "0x…" } }]

Alongside policyTargets and omitRoutes, a reference accepts baseUrl (a self-hosted instance such as GitHub Enterprise), tokenEnv, credentialHeader, timeoutMs, and id. A preset expands to a plain connector and is validated identically — it saves the copying, not the operator's decision.

What ships

Every write below needs a policy target bound before it will register, and every preset can be registered read-only with --omit. lacrew connectors show <id> prints the routes, the args each takes, the credential modes it supports, and what is still unbound.

PresetWhat a crew uses it forWrites (need an address)Credential modes
githubPull requests, files, CI state, file contents, git refs and treesmerge_pull_request, create_issue_comment, and the push (update_file, create_tree, create_commit, update_ref, which bind one address between them)github-app (default) · tokenGH_TOKEN
gitlabMerge requests, diffs, pipelines — gitlab.com or self-hostedmerge_merge_requesttokenGITLAB_TOKEN (PRIVATE-TOKEN)
npmPublished versions, dist-tags, deprecationsnone
pypiRelease history, requires-python, yanked releasesnone
twitterSearch, timelines, one postcreate_tweettokenTWITTER_BEARER_TOKEN
typefullyDraft queue and schedulingschedule_draft (create_draft files a draft and needs none)tokenTYPEFULLY_API_KEY
ghostThe site's posts; files new onescreate_post, update_posttokenGHOST_ADMIN_TOKEN
mediumAlternate publish surfacecreate_posttokenMEDIUM_INTEGRATION_TOKEN
notionBrand voice docs and past posts, read-onlytokenNOTION_TOKEN
uniswapPool state and liquidity via the v3 subgraphtokenGRAPH_API_KEY
tenderlyDry-run a call before proposing ittokenTENDERLY_ACCESS_KEY
coingeckoPrices and market contexttokenCOINGECKO_API_KEY
defillamaProtocol and chain TVL — money leaving before a headline says sonone
defillama-yieldsPool-level APY and its historynone
aaveAave v3 reserve data: supply and borrow rates, liquidity, capsnone
snapshotOff-chain governance: a space's open proposals, and the votes cast on themnone
tallyOnchain governor proposals for the organisations Tally indexestokenTALLY_API_KEY (api-key)

GitHub is the only one that offers an App today, and it is the only one whose service supports the shape. Where a service has something closer to it than a personal token — GitLab's project access tokens, Notion's integration secrets, scoped to what is shared with them rather than to a person — the preset's note says so, so the choice is on screen when you make it.

Six things worth reading off that table:

No DeFi preset has a write at all. A swap, or a supply into a lending market, is an onchain intent that goes through lacrew_propose_intent and the policy stack. A connector that could execute one would be a second execution path with none of that enforcement, so uniswap, tenderly, coingecko, defillama, defillama-yields and aave read and simulate, and nothing else.

Neither governance preset can cast a vote, for a reason that is not the same caution. tally is the DeFi argument again: a vote there is a transaction to a governor contract, which is an intent through the policy stack. snapshot is the sharper case — a Snapshot vote is an EIP-712 message signed by the delegate's own key and posted to a different host, so there is no credential a connector could hold that would authorise one. The desk discovers proposals, decides against its mandate, and writes the instruction a human casts. See how discovery reaches a run for the shape.

DefiLlama is two presets because it is two hosts. A connector has exactly one base URL, and DefiLlama serves TVL from api.llama.fi while yields live on yields.llama.fiapi.llama.fi/pools is a 404. Folding them together would ship a route that fails in the middle of a run, which is the transcription mistake presets exist to prevent, so they are separate and a test pins the two base URLs apart.

Two of those routes are bulk: defillama.list_protocols is around eight megabytes and defillama-yields.list_pools around eleven. Both carry a raised maxResponseBytes so the default 1 MB ceiling does not refuse them outright — see Responses have a size limit. Register a longer timeoutMs, and use them to build a watch list rather than inside a pipeline: the limit bounds what a mistake costs, it does not make one a good idea.

Where the publish gate actually sits. For typefully it is the arg allowlist: create_draft and schedule_draft are the same endpoint, and the first cannot pass a schedule date because the route does not declare one — so filing a draft for a human and putting one on the wire are admitted separately. For ghost and medium the visibility lives in the request body (status, publishStatus), which an allowlist cannot split, so every write there carries publishing authority and is documented as doing so.

Two presets ask for something before they will build. ghost ships no base URL, because the site is yours (--base-url https://<site>/ghost/api/admin); medium authenticates only with a legacy integration token, and Medium no longer issues them — an account without one cannot use that preset at all.

npm and pypi are public and send no credential. Passing --token-env to one is an error rather than a no-op: an operator who names a token there believes one is going out.

The push is a set of routes, not a shell. The github-experts fixer gets a patch onto a bot's PR branch through git's own object API — tree, commit, ref — so a fix touching several files is one commit rather than several. These are the only write paths a crew has to a repository's contents — see What the fixer can and cannot do.

A preset may also pin constant headers the service requires — notion sends Notion-Version, ghost sends Accept-Version. A single route can pin one of its own: github.get_file_raw sends Accept: application/vnd.github.raw so the file comes back as text rather than base64, while github.get_file reads the same endpoint as JSON for the blob sha. Headers are part of the connector, not a flow's args, and one that would shadow the credential is rejected at registration.

What the fixer can and cannot do

The github-experts charter says the fixer "pushes to the bot's PR branch". Git is not one REST call, and the answer is not a shell: an orchestrator that could run git for an agent would be a second execution path with none of the enforcement the first one has. What ships instead is a small set of gated routes over git's own object model, registered with the branches they may land on:

lacrew connectors config github \
  --policy-target push_authority=0x… \
  --branch 'dependabot/**' --branch 'renovate/**'

It can: land a fix of up to twenty files as one commit on an allowlisted branch, having asked lacrew_check_policy about the crew's push-authority address and been answered ALLOW. That is git's own object API, in four calls:

get_ref     → where the branch points
get_commit  → the tree that commit carries
create_tree → a new tree: base_tree plus the files being changed
create_commit → one commit, one parent — the head that was read
update_ref  → the branch moves. This is the push, and this is the one that asks.

One commit means one CI run and one diff for a reviewer, which is the whole reason not to write files one at a time. Only update_ref ships in ask mode: a tree and a commit nothing points at are invisible and get garbage-collected, so confirming them would gate nothing — and three confirmations per push is how an operator learns to approve the one that matters without reading it.

All four bind one address (--policy-target push_authority=0x…), because it is one decision. update_file is also registered for the single-file case: one call, no sha juggling, same authority and same allowlist.

It cannot:

Force-push or rewrite historyThere is no field to do it with. update_ref takes one argument, the commit — a flow cannot pass force, and an undeclared arg is dropped, not sent. Without it GitHub refuses anything that is not a fast-forward, so a branch that moved underneath loses the fix rather than clobbering it.
Write a merge or an orphan commitparents is one value that goes out as a list of one. Two parents is a merge and none is an orphan; neither is expressible.
Add a symlink or a submoduleA tree entry's mode and type are fixed at registration, not allowlisted — 100644 and blob. The values that mean symlink (120000) and submodule pointer (160000) are not values a call can carry. A sha field is dropped, so an entry cannot point at a blob nobody wrote.
Push to a branch nobody admittedThe branch arg is pinned to the globs you registered, and it is required: GitHub commits to the default branch when a write omits it, so a missing branch is a refused call rather than a commit on main.
Escape the repo it namedpath is encoded a segment at a time, and ., .., and empty segments are refused — inside a tree entry as well as in a URL. So is a branch name containing a .. component, ahead of the allowlist.
Touch the workflow files.github/workflows/ is refused as a path prefix by default, on every route that writes a path. --deny-path replaces the list; --deny-path '' keeps only branch protection and CODEOWNERS.
Delete anythingThe DELETE routes on those endpoints are not registered, and no preset ships them.
Upload something enormousA file is capped at 256 KB, a tree at 20 files and 512 KB, and the whole request body at 1 MB. All of them refuse rather than truncate.
Push without being admittedpush-authority is an ordinary whitelist entry. Revoking that one address stops every push the crew can make, org-wide, without touching GitHub.

What it can still get wrong, and what bounds it: an entry naming a file the run never read replaces that file in full. Nothing structural prevents that — the bound is the blast radius rather than the model's discipline, which is why the branch allowlist, the twenty-file cap, the workflow refusal, and a human on the merge all exist. The github-experts blueprint states this as a guardrail with its residual risk rather than implying the policy stack covers it.

Two addresses stay separate from it on purpose. A crew that may push is not thereby allowed to merge its own work, and revoking the push must not also silence the note explaining why a PR is stuck — so push-authority, merge-authority, and comment-authority are three whitelist entries and three governance decisions.

What remains outside LaCrew's reach is the same as before: branch protection, CODEOWNERS, and the scope of the App installation are GitHub's to enforce. The org chart bounds money and authority, not repository access.

Constraining what an argument may say

The param allowlist answers which fields a flow may set. argRules answers what they may say, per route:

{
  "name": "update_file",
  "method": "PUT",
  "path": "/repos/{owner}/{repo}/contents/{path}",
  "effect": "write",
  "params": ["message", "content", "sha", "branch"],
  "argRules": {
    "path": { "multiSegment": true },
    "branch": { "required": true, "pattern": "dependabot/.*" },
    "content": { "encode": "base64", "maxBytes": 262144 }
  }
}
FieldWhat it does
requiredThe call fails without the argument. Body args are optional by default, which is occasionally dangerous.
patternA regex the value must match whole — it is anchored for you, so a prefix cannot slip past dependabot/.+.
oneOfThe complete set of accepted values.
maxBytesA ceiling on the value as the flow supplied it, checked before any encoding.
multiSegmentThe value is a /-separated path: ., .., and empty segments are refused. On a path arg it also encodes per segment so the slashes survive.
encodeBody args only: send the value base64-encoded, so a model can emit plain text for an endpoint that takes base64.
fixedThe value is set at registration and replaces whatever the caller passed. Removes a choice rather than narrowing one.
jsonBody args only: parse the value as JSON first, so a route whose body takes a list can be called from a flow at all. A fenced code block is unwrapped; anything else that is not JSON fails the call.
itemsWith json: each entry is an object rebuilt from these rules. Undeclared keys are dropped, exactly as an undeclared arg is.
maxItemsWith items: how many entries the list may carry.
wrapSend the value as a single-element array — when "exactly one" is the property worth having.

A refused value fails the step with connector_arg_refused:<tool>:<arg> (or connector_arg_too_large:<tool>:<arg>:<limit>) before the request is built, and the error never echoes the value. Rules that constrain nothing are rejected at registration: an argRules entry naming an argument the route does not take is a typo, not a silent no-op.

Registering one by hand

Anything without a preset is written out in full. LACREW_CONNECTORS holds inline JSON or a path to a JSON file, and the two forms mix in one array:

[
  {
    "id": "github",
    "baseUrl": "https://api.github.com",
    "auth": { "kind": "bearer", "tokenEnv": "GH_TOKEN" },
    "routes": [
      {
        "name": "get_pull_request",
        "method": "GET",
        "path": "/repos/{owner}/{repo}/pulls/{number}",
        "effect": "read"
      },
      {
        "name": "merge_pull_request",
        "method": "PUT",
        "path": "/repos/{owner}/{repo}/pulls/{number}/merge",
        "effect": "write",
        "params": ["merge_method"],
        "policyTarget": "0x…"
      }
    ]
  }
]

A flow then calls it like any other tool:

flow("bot-pr-triage").tool("pr", "github.get_pull_request", {
  owner: "{{input.owner}}",
  repo: "{{input.repo}}",
  number: "{{input.number}}",
});

{{input.<key>}} reads a field of a JSON run input, and {{steps.<id>.json.<path>}} reads into an earlier step's result, so a route gets its args without a model being asked to re-extract each one from a blob it already has — the push names the sha the read returned rather than a hash a completion retyped.

What a flow cannot do

Flow definitions arrive as untrusted JSON — from the visual builder, from a marketplace listing. The registry is built on that assumption:

Routes are an allowlist, not a URLA flow names a route the operator wrote down. It cannot compose a URL, change the method, or reach a host nobody admitted.
Path args cannot escape their segment{placeholder} values are percent-encoded, so ../../user/repos stays one segment. A multiSegment arg keeps its slashes but still refuses ., .., and empties.
Undeclared args are droppedOnly names in the route's params reach the query string or body. A definition cannot smuggle admin_override into a request the operator described.
Credentials never enter the flowAuth is read from the environment at call time. A missing credential fails the call rather than sending an unauthenticated one.
http:// is refusedExcept for loopback, so a local tool server still works in development.
Argument values can be pinnedA route's argRules hold a value to a pattern, a set, or a size before the request is built. See Constraining what an argument may say.
Responses have a ceilingA body over the route's limit is refused, not truncated. See Responses have a size limit.
Requests have one tooA body over 1 MB is refused with connector_request_too_large. What a crew sends is bounded by the registration, not by what a model happened to emit.

An invalid connector is rejected at registration, and the orchestrator refuses to boot with one — a silently dropped connector reads to a flow author as "the tool does not exist yet".

Responses have a size limit

A connector's response is stringified into {{steps.<id>.json}} and handed to whatever reads it next — usually a model prompt. Without a ceiling, one call to a bulk listing route is an eleven-megabyte prompt, billed and truncated somewhere downstream where the cause is invisible.

Every route has a limit. The default is 1 MB, and a body over it is refused:

connector_response_too_large:defillama-yields.list_pools:1048576

The step fails with that code. It does not return a truncated body — a half-object is invalid JSON, so it would reach a model as a string that looks like data and reasons like noise, and nothing downstream could tell that from a real answer. A refusal is something an operator can act on; a truncation is a wrong answer nobody sees.

The refusal is on the audit trail as a ToolCalled row with ok: false and refused: "response_too_large", carrying the limit that applied. The response body is not on it — the reason for refusing a body is not a reason to record it.

Where the size is a property of the endpoint rather than the deployment, the route says so. The bulk DefiLlama routes ship with their own raised ceilings, because reading every pool once to build a watch list is what they are for:

RouteLimit
defillama.list_protocols16 MB
defillama.get_protocol64 MB
defillama-yields.list_pools16 MB

lacrew connectors show <id> prints the default and any route that raises it.

Set your own at either level — a route's limit wins over its connector's, which wins over the default:

{
  "id": "reports",
  "baseUrl": "https://reports.example",
  "auth": { "kind": "bearer", "tokenEnv": "REPORTS_TOKEN" },
  "maxResponseBytes": 262144, // everything here, unless a route says otherwise
  "routes": [
    { "name": "get_summary", "method": "GET", "path": "/summary/{id}", "effect": "read" },
    {
      "name": "export_all",
      "method": "GET",
      "path": "/export",
      "effect": "read",
      "maxResponseBytes": 33554432, // the bulk one, raised deliberately
    },
  ],
}

Raising a limit is not the only answer, and usually not the right one. A route that returns megabytes is a route whose result should be filtered before a model ever sees it — call the narrow endpoint, or pass the parameters that narrow the bulk one.

Connector or MCP server?

A connector is HTTP routes you wrote down, and it is the only one of the two that can bind a write to a policyTarget — reach for it when the action is one the policy stack should answer for.

When the surface already speaks MCP, attach it instead of transcribing it: External MCP servers composes somebody else's server behind the same auto / ask / deny vocabulary, with every tool blocked until an operator allows it by name. Both are registered per workspace, both are audited, and neither can widen what the chain admits.

Writes ask the policy stack

A route with effect: "write" may carry a policyTarget: an address standing for the authority to take that action. Before the call, the registry asks the policy stack about that address, and anything but ALLOW refuses the call — ESCALATE included, because a pending approval is not permission.

This gives an action the same admission mechanism money has. The crew's ability to merge pull requests is one whitelisted address: admitting it is a governance proposal, and revoking it turns merging off org-wide in a single action, without touching GitHub or redeploying anything.

Flows are expected to ask first and route on the answer, so a refusal is a branch rather than a failed run:

  .tool("merge-check", "lacrew_check_policy", { target: "{{target.merge-authority}}", value: "0" })
  .branch("may-merge", {
    when: { source: "{{steps.merge-check.json}}", op: "contains", value: "\"ALLOW\"" },
    onTrue: "merge",
    onFalse: "merge-blocked",
  })

The registry re-checks regardless, so a flow that skipped the question still cannot merge. The check is the courtesy; the registry is the control.

Writes also run in a mode: auto, ask, deny

A policy target answers "is this crew admitted to do this at all". It cannot answer the question operators ask constantly, which is "policy allows the merge, and I still want to see it first". So every write route also has a mode, and the vocabulary is the onchain one turned outward:

Onchain verdictWrite modeWhat happens
ALLOWautoadmitted, and called without asking
ESCALATEaskadmitted, and a human confirms in-thread before the call
DENYdenynever called, and the network is never reached

The parallel is deliberate. An operator who has learned what ESCALATE means for a spend already knows what ask means for a publish.

A mode only ever narrows. auto admits nothing — a route with a policyTarget is still checked against the stack first, and a DENY there refuses the call whatever the mode says. The most this control can do is require a confirmation or refuse outright, which is why an operator cannot widen a crew's reach by editing a dropdown. Reads carry no mode at all: a confirmation that gates nothing teaches people to click through the ones that matter.

The three refusals are distinct codes, because they send an operator to different places:

CodeCauseWhere the fix is
connector_mode_deniedmode is denythe mode rule
connector_deniedpolicy stack said DENY / ESCALATEgovernance
connector_ask_declineda human answered nonowhere — it worked
connector_ask_timeoutnobody answered in timethe question, still in the thread

What ask actually does

The run stops. It does not block: a person answers in minutes or hours, and a run that waited would tie a funded crew's work to one process surviving a redeploy. The step posts a question into the principal's thread, the run is suspended to durable state with status waiting, and whichever replica handles the answer resumes it at the same step.

… pr-merge · waiting · 1 steps · run run-abc
  waiting on a human to confirm github.merge_pull_request (ask_9f2c…)
  Answer it:  lacrew connectors asks

Only yes and no count. "sure, go ahead" is a sentence a person means as a yes and a parser can only guess at, and a wrong guess is a merge nobody authorised — so free text resolves nothing, the question is re-posted, and the write stays in the queue.

A confirmation is keyed to the request, not to the route: method, rendered path, and the fields the route forwards are hashed into a fingerprint. Merge a different pull request and it is a different ask with its own question. One yes is spent once and never applies again, including across a restart.

An ask that nobody answers expires (default 24 hours, LACREW_CONNECTOR_ASK_TTL_MS) and the step fails closed. Nothing is called.

The confirmation is a claim, not an approval

Answering yes releases a step the policy stack had already admitted. It admits nothing on its own, and a write that also moves money still raises its intent and still meets the escalation path. The answer is an ordinary conversation message with an ordinary author, resolved server-side; there is no route that resolves an ask directly, because one would be a second way to release a write with no record in the thread.

Setting a mode

Presets ship ask on the routes whose mistakes are public and hard to take back — merge a pull request, publish a post, send a tweet. Typefully's create_draft does not, because that route cannot publish.

lacrew connectors modes                                   # rules + what each mode means
lacrew connectors mode github.merge_pull_request ask      # workspace-wide
lacrew connectors mode github.* deny --scope agent:0x…    # one seat, every route
lacrew connectors mode github.merge_pull_request --clear  # back to what it inherits

Rules resolve narrowest-first — agent, then crew, then workspace, then the route's own default — and an exact route beats a <connector>.* at the same level. A crew rule names the node it hangs from and applies to every seat below it, so "this desk never publishes" is one rule rather than one per worker. Clearing a rule is not the same as setting auto: it removes the exception, so the route goes back to inheriting.

A scope is an address, not a name

crew: in a scope is the address of an org-chart node — the seat a team hangs from — and the rule covers everything below it. It is not a team's name, and the two are not interchangeable: a name is a label a workspace can rename, reuse, or attach to a group of seats that share no manager at all. There is no mapping from one to the other that stays true, so nothing here invents one.

If you think of a team by its name, expand it to the seats you mean and set the rule on each of them. Both the CLI and the Tools drawer do exactly that: they show the addresses, and they apply to the ones you picked. Picking one member's address on a team's behalf would put a deny somewhere nobody chose, and the operator would go on believing the whole team was covered.

What one seat actually runs under

The rule list says what was decided. It does not say what a given worker runs under, because a rule set two levels up reaches it without naming it. ?as= answers that question directly, for as many seats as you want to compare (up to 50 — beyond that it is a scrape, and it is refused rather than truncated):

lacrew connectors modes --as 0xworker…
lacrew connectors modes --as 0xworker…,0xother…      # a whole desk, one request
curl -s "localhost:8788/connectors/modes?as=0xworker…" | jq .
{
  "rules": [
    { "scope": { "level": "crew", "ref": "0xdesk…" }, "route": "github.*", "mode": "deny" }
  ],
  "modes": ["auto", "ask", "deny"],
  "effective": [
    {
      "principal": "0xworker…",
      "managers": ["0xdesk…", "0xroot…"],
      "routes": [
        {
          "route": "github.merge_pull_request",
          "mode": "deny",
          "source": {
            "kind": "rule",
            "scope": { "level": "crew", "ref": "0xdesk…" },
            "route": "github.*"
          }
        }
      ]
    }
  ]
}

managers is the reporting line the modes resolved through, nearest first, so a surface can say which ancestor decided a value instead of printing "inherited" and leaving the operator to hunt for the rule. source.kind is route-default when nobody has decided anything — which is a different fact from somebody having chosen auto, and worth showing as one. The chart is read once per request, so asking about a whole desk costs what asking about one seat does.

Working the queue

lacrew connectors asks                                    # writes waiting on a human
lacrew connectors answer ask_9f2c yes --as human:ops

The same questions appear in the Questions rail and on GET /messages, because they are the same messages. Every ask emits ConnectorAsk when it is raised and ConnectorAskResolved when it ends — approved, declined, or expired. The payload carries the fingerprint and never the arguments: a rendered path routinely names a private repository, and the trail is not the place to publish one. Changing a mode emits ConnectorWritePolicyChanged, because moving a merge from ask to auto removes the human from every future merge and that should be attributable to whoever did it.

One gap worth knowing: an ask-mode write inside a delegated flow (an agent step naming another flow) fails the delegating step rather than suspending. The ask holds the child run's state, and releasing it would leave the parent parked with nothing to continue it.

Asking what is actually wired

Connectors are configured from the environment, so an operator surface has no way to guess whether GitHub is hooked up. GET /connectors answers it:

curl -s localhost:8788/connectors | jq .
{
  "connectors": [
    {
      "id": "github",
      "baseUrl": "https://api.github.com",
      "auth": { "kind": "bearer", "envVars": ["GH_TOKEN"], "ready": true },
      "routes": [
        {
          "name": "get_pull_request",
          "method": "GET",
          "effect": "read",
          "policyTarget": null,
          "mode": null,
          "effectiveMode": null
        },
        {
          "name": "merge_pull_request",
          "method": "PUT",
          "effect": "write",
          "policyTarget": "0x…",
          "mode": "ask",
          "effectiveMode": {
            "mode": "deny",
            "source": { "kind": "rule", "scope": { "level": "workspace" }, "route": "github.*" }
          }
        }
      ]
    }
  ],
  "available": [{ "id": "…", "title": "…" }]
}

connectors is what is registered; available is the presets that ship and are not. Keeping them apart is the point — a catalog that merges them tells an operator a crew can merge pull requests when nothing is wired.

mode is what the route declares; effectiveMode is what would actually apply, and what decided it. Pass ?as=0x… to resolve it for one seat — without it the answer is the workspace's, which is the one nobody's flow runs under once a single override exists.

auth names the environment variables the connector reads and whether they are set. Never a value: "is my token there?" is answerable without reading it, and a status route that reads it is an exfiltration route. A github-app connector also reports whether an installation token is currently held and when it expires — again, not the token.

Every call is on the audit trail

Each call emits a ToolCalled event: connector, route, method, effect, status, duration, whether a policy check gated it, and the mode a write ran in. Never the response body — a PR diff or a draft post has no business in an audit row — and never the credential.

A write row is a crew acting on the world, which makes this the trail an operator reads when asking what their agents actually did.

Offline

Without a registered connector, createMockFlowBackend answers a connector-shaped tool name with { ok: false, note: "no connector registered — nothing was called" } so offline runs complete without inventing a response. A misspelled lacrew_* name still throws.

Blueprints declare what they need

A crew blueprint lists the connectors its flows call, so the operator knows what to register before standing the crew up:

lacrew crews show github-experts
# Connectors to register before the crew can work
#   github  (github.get_pull_request, github.list_pull_request_files, github.get_file_raw,
#            github.get_ref, github.get_commit, github.create_issue_comment,
#            github.create_tree, github.create_commit, github.update_ref,
#            github.merge_pull_request)
#      ships as a preset:  lacrew connectors show github

validateCrewBlueprint rejects a blueprint whose flows call a route no declared connector serves.

How discovery reaches a run

A connector call needs arguments, and the run input is where they come from. For most flows that is a detail; for a crew whose job is to find work it is the whole shape, so the governance desk is worth walking through.

Before the snapshot preset, governance-vote-cycle took the proposal itself:

// run input — a human had already found this and pasted it in
{ "proposalId": "42" } // plus the proposal text as the run's prose input

That flow still ships, and it is the right one when somebody is already looking at a proposal. What it cannot do is start. governance-proposal-sweep takes the place to look instead:

// run input — the space, and nothing about any particular proposal
{ "space": "aavedao.eth" }

and the first step turns that into a payload:

.tool("queue", "snapshot.query", {
  query:
    'query { proposals(first: 3, where: { space_in: ["{{input.space}}"], state: "active" }, ' +
    'orderBy: "created", orderDirection: desc) { id title body choices state start end ' +
    'quorum scores scores_total link author space { id name } } }',
})

Three things about that step generalise to any discovery flow:

The query is parameterised by the run input and nothing else. {{input.space}} is interpolated; no {{steps.*}} reference appears anywhere in it. A flow that let a model completion name the next id would be interpolating a completion into a GraphQL string, and the queue would be whatever the model wrote. Where a second call genuinely needs a value the first one returned, use {{steps.<id>.json.<path>}} to read it out of the result rather than asking a model to retype it — and prefer projecting the fields you need in the first query, as the sweep does, so there is no second call to parameterise.

The result bound is in the query. first: 3 is there because each proposal carries its body and the whole result is read by a model. The route's maxResponseBytes bounds the transport; it does not bound the prompt, and the place to bound the prompt is the query.

Nested braces need a space. where: { space_in: [...] } is written with spaces inside the braces on purpose: an adjacent {{ is read as an interpolation placeholder and eaten before the query is ever sent. This bites every GraphQL route, which is why the desk's and the LP advisor's flows both say so in their descriptions.

The sweep ends in an instruction rather than a vote — see what ships for why the governance presets have no write route. The instruction names the proposal, the choice, the deadline and the mandate clause, which is the thing a mandate owner can act on in one read.

On this page