Announcing CIMD support for MCP Client registration
Learn more

How to Build a DevOps Assistant Agent with GitHub, Linear, and Slack

Kuntal Banerjee
Founding Engineer

TL;DR

  • The agent polls a GitHub repo for open pull requests every 30 seconds, opens a Linear issue for every new PR-and-label pair it has not seen before, posts a Slack notification for each one, and sends a daily digest listing open PRs with reviewers, stale flags, and their linked Linear issue IDs.
  • It polls rather than listening for webhooks, and that is a deliberate architectural choice. No public endpoint, no HMAC signature verification, no replay handling, no always-on service. The cost is up to 30 seconds of latency and a rate-limit budget you have to respect.
  • Idempotency is what makes polling survivable. A local state file maps owner/repo#number:label to a Linear issue ID and is checked before every create, so restarting the poller does not duplicate issues.
  • The three connectors expose 370 tools between them: GitHub ships 217, Slack 91, and Linear 62. This agent names three of them. That gap is the whole argument for scoped tool surfaces.
  • The correct identity is different per connector. GitHub and Linear should run as the engineer; Slack can run as the app. The repo uses three separate identifier variables for exactly this reason.
  • Clone the DevOps assistant agent repo, configure three connections, and have it running in about 20 minutes.

Why This Agent Polls Instead of Listening for Webhooks

Someone on your team labels a pull request bug and assumes that means something. It does not. The label is a string attached to a PR in GitHub. Nothing in Linear knows it exists. The PR gets reviewed, gets merged, and the follow-up work that the label was standing in for never becomes a tracked issue. Two sprints later somebody asks why a known defect was never picked up, and the answer is that it was picked up, in a place the planning tool cannot see.

Every engineering org has a version of this seam. Work is described in the code host and planned in the tracker, and the two are joined by a human remembering to do it twice. An agent closes that seam in about 400 lines. But the first decision you make when you build it is the one most teams get wrong, and it happens before you write a line of business logic.

The obvious design is a webhook. GitHub fires pull_request.labeled, your service receives it, you create the Linear issue. Event-driven, near-instant, no wasted calls.

Now count what shipping that actually requires. A public HTTPS endpoint that stays reachable. HMAC signature verification against a shared secret on every request, because an unauthenticated webhook endpoint is a stranger writing into your issue tracker. A secret rotation path for when that shared secret leaks. Deduplication, because webhook delivery is at-least-once and GitHub will redeliver. Ordering tolerance, because events do not arrive in the order they happened. Repository admin access to install the hook, per repo. And a service that must be up at the moment the event fires, because a webhook you miss is gone.

The polling version is a process and a while loop.

POLL_INTERVAL = 30 # seconds

That is the entire infrastructure decision. No inbound surface means no inbound attack surface, no signature verification, and no deployment target beyond somewhere a Python process can run. Restarts are free. Local development is the same code path as production.

The tradeoffs are real and worth naming before you commit. Latency is bounded by the poll interval, so a label added at 12:00:01 becomes a Linear issue at 12:00:30. Every cycle spends API budget whether or not anything changed; GitHub's authenticated REST limit is 5,000 requests per hour, so one repo at 30-second intervals is comfortable and forty repos on the same token is not. And polling sees state, not events: if someone adds a label and removes it inside one interval, the agent never knows it happened.

For a single-repo assistant that reconciles current state, polling is the right call. For anything that must react in under a second, or that needs the transition rather than the state, webhooks earn their setup cost.

The Auth Surface: Three Connectors, Three OAuth Apps You Own

None of these three connectors is a drop-in. Each requires you to register your own OAuth application and hand the credentials to Scalekit, which is the bring-your-own-OAuth pattern. That is the correct default for production, since the consent screen your engineers see should name your app, not a vendor's. It also means three separate registration flows before the agent runs once.

GitHub: 217 Tools, and a Boundary OAuth Apps Cannot Cross

The GitHub connector authenticates with OAuth 2.0 and exposes 217 tools. Setup is a round trip: create the connection in the Scalekit dashboard, click Use your own credentials, copy the redirect URI, paste it into your OAuth app's Authorization callback URL at GitHub Developer Settings, then bring the Client ID and Secret back.

One boundary matters for a DevOps agent specifically. github_check_run_create requires a GitHub App; OAuth apps and authenticated users cannot create check runs at all. An OAuth-backed agent can read CI state through github_check_runs_list_for_ref but can never write it. If your roadmap includes the agent posting its own status check onto a PR, that is a GitHub App, not this connector, and it is better to learn that now than after you have built the feature.

Two other scope facts worth knowing before you widen this agent: github_dependabot_alerts_list and github_code_scanning_alerts_list both need the security_events scope on private repositories, and github_notifications_list caps per_page at 50 rather than the usual 100.

Linear: Every Write Needs an ID, and the Docs Are Emphatic About Where to Get It

The Linear connector is OAuth 2.0 with 62 tools. Registration happens at Linear under Settings, then API, then OAuth applications, pasting Scalekit's redirect URI into Callback URLs.

The operational quirk is that Linear is ID-driven. linear_issue_create requires teamId and title; everything else, including assigneeId, labelIds, priority, projectId, and stateId, is optional but also an ID. Linear's connector docs are blunt about this: fetch IDs from the API, never guess or hard-code them, with linear_teams_list returning team IDs at teams.nodes[].id.

This agent takes the shortcut and reads LINEAR_TEAM_ID from .env, which you find by hand once. That is fine for a single-team deployment and it is the first thing to change if you roll this out across teams. Resolving team IDs at startup with linear_teams_list costs one call and removes a class of copy-paste failure.

Slack: Distribution Is the Setting That Breaks the Second Workspace

The Slack connector is OAuth 2.0 with 91 tools. Create the app at the Slack API site, add Scalekit's redirect URI under OAuth and Permissions.

Then do the step people skip. Under Manage Distribution, complete Slack's checklist and click Activate Public Distribution. Skip it and OAuth succeeds in your own development workspace and fails the first time a user tries to connect a second one. The failure arrives weeks after the code worked, in someone else's workspace, which is the worst possible time to discover a configuration setting.

Scalekit's dashboard also asks you to choose Bot scope or User scope on this connection, and for this agent the docs' recommendation is right: choose Bot scope. The agent posts notifications into a shared channel; there is no reason for those to appear under an individual engineer's name.

That answer is connector-specific, not global, and the next section is why.

Recommended Reading: DevOps assistant agent for GitHub, Linear, and Slack, and related MCP variants of these connectors.

Three Identifiers, Not One, Because the Right Identity Differs Per Connector

Most agent tutorials pass one user ID everywhere. This repo carries three:

GITHUB_IDENTIFIER=your_github_identifier LINEAR_IDENTIFIER=your_linear_identifier SLACK_IDENTIFIER=your_slack_identifier

That is not redundancy. Each connector answers a different question about whose authority backs the call, which is the core of how delegated agent access works in practice.

GitHub should be the engineer. PR visibility is bounded by what the authorizing account can see. An agent running on one engineer's connected account surfaces the private repos that engineer has access to and no others. Swap in a shared org-wide token and the agent's reach becomes the union of everything, which is both more access than any individual has and the reason security review will ask about it. What the user cannot see, the agent cannot see, and that property is free here rather than something you enforce in application code.

Linear should be the engineer. Issues created through a delegated connected account carry that person's identity. Created through a shared bot, every auto-filed issue is attributed to the bot, and the audit question "who opened this" has one useless answer for the entire backlog.

Slack can be the app. The agent posts to a team channel. Bot identity is correct and Slack's own model prefers it.

The one place that flips is search. slack_search_messages, slack_search_files, and slack_search_all all require search:read, which Slack supports on user tokens only and not on bot tokens. This agent never searches, so bot scope is fine. Extend it to find related discussion and you have changed the identity requirement, not just added a feature.

Recommended Reading: credential ownership patterns for agent tool calling, and agent tool calling auth production problems, patterns, and anti-patterns.

370 Tools Available, Three Called

Add up the catalogs and this agent's three connectors expose 370 tools: 217 from GitHub, 91 from Slack, 62 from Linear.

The agent calls three.

github_pull_requests_list list open PRs linear_issue_create open a linked issue slack_send_message notify and digest

That ratio is the practical case for scoped tool surfaces. Hand a model the full catalog and you have put 370 schemas in context before it does any work, which at roughly 200 tokens each is on the order of 74,000 tokens of pure overhead per run. Accuracy degrades in the same direction: an LLM asked to pick one action from 370 options, including near-duplicates like slack_archive_channel and slack_archive_conversation, or github_issue_labels_add and github_issue_labels_set, will pick wrong more often than one choosing from five.

This agent gets there by a blunter route: it is a deterministic pipeline with no model in the loop choosing anything. Poll, compare, create, notify. The tools are named in code. That is worth saying plainly, because a lot of things labelled "AI agent" are this, and there is nothing wrong with that when the work is genuinely deterministic.

Prerequisites

  • Python 3.10 or newer
  • A Scalekit account; the free tier is enough
  • OAuth apps registered with GitHub, Linear, and Slack, with Scalekit's redirect URI added to each
  • A GitHub repo with at least one labeled open PR to test against
  • A Linear team ID, and a Slack channel ID for the digest
  • scalekit-sdk-python >= 2.12.0

How to Set Up Your Connectors in Scalekit

Step 1: Create Your Workspace

Create a workspace and copy SCALEKIT_ENV_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET into .env.

Step 2: Add GitHub, Linear, and Slack Connections

Create all three under AgentKit, then Connections. Each follows the same shape: copy the redirect URI, register it with the provider, bring back Client ID and Secret. For GitHub, grant repo read scope so the agent can list PRs in private repositories. For Slack, activate distribution before you finish.

Step 3: Pin Every Call to an Exact Connection Name

Scalekit auto-suffixes connection names per workspace. What you see in the dashboard is github-g0DJbhbx or slack-sKfekCVz, not the bare provider label. If one identifier maps to more than one connection for the same service, resolving by identifier alone is ambiguous and the call fails with a multiple-accounts error.

Copy the exact names from Connected Accounts into .env:

GITHUB_CONNECTION_NAME=github-g0DJbhbx LINEAR_CONNECTION_NAME=linear-wuvcVfMm SLACK_CONNECTION_NAME=slack-sKfekCVz

Every execute_tool call in this agent passes connection_name. Set these even when you only have one connection per service, because the day you add a second is not the day you want to discover why the agent picked the wrong one.

Setting Up Auth with Claude Code

claude plugin marketplace add scalekit-inc/claude-code-authstack && claude plugin install agent-auth@scalekit-auth-stack

Then prompt Claude Code:

Set up Scalekit auth for GitHub, Linear, and Slack. I need to list open pull requests, create Linear issues, and post Slack messages. Use actions.execute_tool() for all three, pass connection_name on every call, and wrap it with retry and auth-expiry handling.

What comes back is a single wrapper class that every call in the agent goes through.

class ScalekitConnector: """Wraps ScalekitClient to provide retry, connection pinning, and auth expiry detection.""" def __init__(self): self.client = ScalekitClient( env_url=Settings.SCALEKIT_ENV_URL, client_id=Settings.SCALEKIT_CLIENT_ID, client_secret=Settings.SCALEKIT_CLIENT_SECRET, ) self._pr_links = self._load_pr_links()

Auth Expiry Turns Into a Link, Not a Stack Trace

This is the part of the wrapper worth copying into your own agents. A long-running poller will outlive at least one token, and the default failure mode is a cryptic exception at 3am on a Sunday. Instead, the agent watches for the specific error signals that mean a credential is dead and responds by generating a fresh authorization link into the logs.

_AUTH_ERROR_SIGNALS = ("invalid_auth", "token_expired", "token_revoked", "not_authed") def _maybe_generate_auth_link(self, identifier: str, connection_name: Optional[str], error_str: str) -> None: """If the error looks like an expired/revoked token, generate and log a re-authorization link so the user knows exactly what to do.""" if not any(sig in error_str for sig in _AUTH_ERROR_SIGNALS): return try: resp = self.client.actions.get_authorization_link( identifier=identifier, connection_name=connection_name or None, ) link = getattr(resp, "link", None) or getattr(resp, "url", None) or str(resp) expiry = getattr(resp, "expiry", None) expiry_str = f" (expires {expiry})" if expiry else "" log.error( "AUTH EXPIRED for identifier=%s connection=%s — re-authorize here%s:\n %s", _mask(identifier), connection_name or "auto", expiry_str, link, ) except Exception: log.error( "AUTH EXPIRED for identifier=%s connection=%s but could not generate re-auth link: %s", _mask(identifier), connection_name or "auto", traceback.format_exc(), )

Note _mask() on the identifier. Identifiers are usually emails or user IDs, and they end up in log files that outlive the incident, so they are truncated to first two and last two characters before anything is written.

Scalekit refreshes tokens on its own; this path only fires on genuine revocation, which refresh cannot fix. The distinction matters because the two failures look identical from inside the agent and only one of them requires a human, and it is the reason token lifecycle belongs outside the agent runtime.

Retry Wraps Every Call, and Returns None Instead of Raising

def execute_tool( self, identifier: str, tool: str, parameters: Dict[str, Any], connection_name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Call a Scalekit Action tool with automatic retry and auth expiry detection. Retries on transient errors (timeout, rate limit, connection reset) with exponential backoff. On auth expiry signals, generates and logs a re-auth link automatically. Returns None on permanent failure so callers can skip gracefully without crashing the poll loop. """ backoff = Settings.RETRY_BACKOFF for attempt in range(1, Settings.RETRY_ATTEMPTS + 1): try: log.debug( "execute_tool attempt=%d tool=%s identifier=%s connection_name=%s", attempt, tool, _mask(identifier), connection_name, ) resp = self.client.actions.execute_tool( tool_input=parameters, tool_name=tool, identifier=identifier, connection_name=connection_name, ) result = resp.data if hasattr(resp, "data") else resp log.debug("Tool %s succeeded (type=%s)", tool, type(result).__name__) try: preview = json.dumps( result if isinstance(result, (dict, list)) else str(result), indent=2 )[:500] log.debug("Response preview: %s", preview) except Exception: pass return result

The success path returns resp.data and logs a truncated preview at DEBUG, which is how you inspect an unfamiliar response shape without turning it on in production. The error path is where the design decision lives.

except ScalekitException as e: error_str = str(e).lower() log.warning("ScalekitException on attempt %d for tool %s: %s", attempt, tool, e) # Check for expired/revoked token — generate re-auth link immediately. self._maybe_generate_auth_link(identifier, connection_name, error_str) is_auth_error = any(sig in error_str for sig in _AUTH_ERROR_SIGNALS) retryable = ( any(x in error_str for x in ["timeout", "rate", "connection", "temporary", "unavailable"]) and attempt < Settings.RETRY_ATTEMPTS ) if retryable: log.info("Retrying %s after %ds (attempt %d)...", tool, backoff, attempt) time.sleep(backoff) backoff *= 2 continue if not is_auth_error: log.error("Permanent failure for tool %s; returning None.\n%s", tool, traceback.format_exc()) return None except Exception: log.exception("Unexpected exception calling tool %s.", tool) return None

Returning None rather than raising is the design decision that keeps a poller alive. A transient Linear outage should skip one label, not kill the process. The cost is that every caller has to check for None, and the digest sender does exactly that, because a silently-None Slack call is otherwise indistinguishable from a delivered message.

Step 1: Fetch Open Pull Requests

params = {"owner": Settings.GITHUB_REPO_OWNER, "repo": Settings.GITHUB_REPO_NAME, "state": "open"} resp = conn.execute_tool( identifier=GITHUB_IDENTIFIER, tool="github_pull_requests_list", parameters=params, connection_name=Settings.GITHUB_CONNECTION_NAME or None, ) or {} prs: List[Dict[str, Any]] = [] for key in ("array", "items", "pull_requests", "data"): if key in resp: value = resp[key] if isinstance(value, list): prs = value else: log.warning("PR response field '%s' was not a list (got %s); treating as no PRs.", key, type(value).__name__) break else: log.warning("PR response had none of the expected keys (array/items/pull_requests/data): %s", list(resp.keys()))

The key-order walk exists because list responses do not always arrive under the same field name, and the for/else makes the miss loud rather than silent. That is the right instinct: an agent that treats an unrecognized response shape as "zero PRs" will run for a week looking healthy while doing nothing.

Step 2: Idempotency, Which Is What Makes Polling Safe

Polling means seeing the same labeled PR every 30 seconds forever. Without a memory, that is one Linear issue every 30 seconds.

The key is the smallest thing that uniquely identifies the unit of work:

def build_label_key(full_name: str, number: int, label: str) -> str: """Build a normalized idempotency key for a PR + label combination.""" return f"{full_name}#{number}:{label}".lower()

PR plus label, not PR alone. A PR labeled both bug and security should produce two issues routed to two teams, and lowercasing means Bug and bug do not diverge into duplicates.

The map lives in state/pr_linear_links.json and is checked before every create:

linear_issue_id = conn.get_linear_issue_for_pr(key) if linear_issue_id: log.info("Already linked Linear issue for key: %s (id: %s)", key, linear_issue_id) return

There is one failure window the code handles honestly rather than hiding. If the Linear issue is created and the state write then fails, the issue exists but the agent does not know it, and the next poll will create a second one. Rather than pretend otherwise, it logs loudly at the exact moment:

try: conn.record_pr_issue(key, linear_issue_id, label) log.info("Recorded Linear issue %s for key: %s", linear_issue_id, key) except Exception: log.error( "Linear issue %s was created for key '%s' but recording it to local state FAILED. " "A duplicate issue may be created on the next poll. Error: %s", linear_issue_id, key, traceback.format_exc(), )

A JSON file on local disk is the right amount of machinery for a single-instance poller and the wrong amount for two. Run a second instance against the same repo and both will read a stale map and both will create the issue. Moving the map to Redis or Postgres with the key as a unique constraint is the change that makes this horizontally safe, and it is a small one.

Step 3: Create the Linear Issue

team_id = str(Settings.LABEL_TO_LINEAR_TEAM.get(label) or Settings.LINEAR_TEAM_ID) if not team_id: log.warning("No team_id configured for label '%s'; skipping.", label) return safe_title = title.replace("{", "(").replace("}", ")") safe_label = label.replace("{", "(").replace("}", ")") safe_descr = f"Auto-created from PR #{number} in {full_name}\nURL: {url}\nLabel: {safe_label}" issue_params = { "title": f"PR: {safe_title} [{safe_label}]", "description": safe_descr, "teamId": team_id, } result = conn.execute_tool( identifier=LINEAR_IDENTIFIER, tool="linear_issue_create", parameters=issue_params, connection_name=Settings.LINEAR_CONNECTION_NAME or None, )

LABEL_TO_LINEAR_TEAM is a JSON map in .env, so {"bug":"TEAM-1","infra":"TEAM-2"} routes labels to different Linear teams without touching code. Labels with no mapping fall through to LINEAR_TEAM_ID.

The brace substitution is not cosmetic. PR titles regularly contain {} from code snippets, and templating layers downstream will try to interpret them. Replacing braces before they reach the tool input is cheaper than debugging why one PR in fifty fails.

Response parsing then walks several shapes, because a create can come back as a raw GraphQL envelope or as a flattened object:

linear_issue_id = None if isinstance(result, dict): try: linear_issue_id = result["data"]["issueCreate"]["issue"].get("id") except (KeyError, TypeError, AttributeError): pass if not linear_issue_id: linear_issue_id = result.get("id") or result.get("issue_id") or (result.get("data") or {}).get("id") if not linear_issue_id: log.error("Linear issue creation for key '%s' returned no usable issue id. Response was: %s", key, result) return

Returning early without an ID is correct. Recording a placeholder would poison the idempotency map permanently.

Step 4: The Daily Digest

Once per calendar day the agent posts a summary of every open PR:

stale = " (stale)" if (updated_date and (today - updated_date).days >= Settings.DIGEST_STALE_DAYS) else "" reviewer_text = ", ".join(reviewers) if reviewers else "none" pr_key_prefix = f"{Settings.GITHUB_REPO_OWNER}/{Settings.GITHUB_REPO_NAME}#{number}:" linked_issues = [v["linear_issue_id"] for k, v in pr_linear_links.items() if k.startswith(pr_key_prefix)] linear_text = f" | Linear: {', '.join(linked_issues)}" if linked_issues else "" lines.append(f"- #{number} {title}{stale} | reviewers: {reviewer_text}{linear_text}{ci_status} | {url}")

The prefix match is why the key format matters. Because keys are owner/repo#42:label, a startswith on owner/repo#42: collects every issue linked to PR 42 regardless of how many labels it carries, with no second index.

Digest delivery checks for the silent failure explicitly:

if result is None: log.error( "Slack digest NOT sent — tool returned None. " "Check SLACK_IDENTIFIER / SLACK_CONNECTION_NAME and that the account is active." ) else: log.info("Slack digest sent successfully.")

Worth being straight about one thing: the digest's CI column is a placeholder in the shipped code. It renders CI: (not implemented) on every line. Wiring it up is a genuinely small change, covered in the extension section below.

How to Run It

python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt python poller.py
2026-06-18 12:00:00 | INFO | DevOps poller started (Scalekit-only). Press Ctrl+C to stop. 2026-06-18 12:00:00 | INFO | Fetching PRs for acme-corp/backend 2026-06-18 12:00:01 | INFO | Found 3 open PR(s). 2026-06-18 12:00:01 | INFO | Looping over 3 PR(s)... 2026-06-18 12:00:01 | INFO | Processing PR #42: 'fix: auth middleware' with labels: ['bug'] 2026-06-18 12:00:01 | INFO | Creating Linear issue for key: acme-corp/backend#42:bug 2026-06-18 12:00:02 | INFO | Recorded Linear issue LIN-123 for key: acme-corp/backend#42:bug 2026-06-18 12:00:02 | INFO | Slack notification sent. 2026-06-18 12:00:02 | INFO | Processing PR #41: 'chore: bump deps' with labels: [] 2026-06-18 12:00:02 | INFO | Sending daily Slack digest... 2026-06-18 12:00:04 | INFO | Slack digest sent successfully. 2026-06-18 12:00:04 | INFO | Daily digest cycle complete. 2026-06-18 12:00:04 | INFO | Loop took 4.12s, sleeping for 25.88s.

Second cycle, same labeled PR:

2026-06-18 12:00:30 | INFO | Processing PR #42: 'fix: auth middleware' with labels: ['bug'] 2026-06-18 12:00:30 | INFO | Already linked Linear issue for key: acme-corp/backend#42:bug (id: LIN-123)

No duplicate. That line is the whole idempotency design paying off, and it is the first thing to check on your own first run.

Logs go to the console with color when attached to a terminal and to logs/poller.log as plain text, rotating at 2 MB with 5 backups. Colors auto-disable when piped, so systemd journals stay readable.

What to Change Before You Run This Unattended

The digest day resets on restart. last_digest_day is tracked in memory inside the run loop, not on disk. Restart the process and the next cycle sends another digest, which during a deploy-heavy afternoon means several. Persisting the last digest date alongside the link map is a two-line fix and worth making before this runs as a service.

Validation warns where it should stop. Missing identifiers raise correctly, but the broader config check logs a warning and continues, so a wrong GITHUB_REPO_OWNER surfaces as an empty PR list rather than a startup error. Promoting that warning to a hard exit turns a confusing runtime symptom into an obvious one.

Decide what happens on label removal. The agent creates on first sight and never reconciles. Remove a label and the Linear issue stays. That is defensible, since work already filed probably should not vanish, but it should be a decision you made rather than one the code made for you.

Give the agent a real identity per engineer. The repo uses fixed identifiers from .env, which is right for a demo and wrong for a team. In production, pass each engineer's real user ID as the identifier and surface an authorization link from your own application whenever their connected account is not active.

Wire observability to the question you will be asked. The agent's own logs cover its run. The question a security review asks is different: which identity, calling which tool, against which connector, and when. Tool call logs and audit trails for agent auth carry that, and the launch checklist is the short version of what to confirm first.

Extending the Agent

Add real CI status. Call github_check_runs_list_for_ref with the PR's head SHA and replace the placeholder in the digest formatter. Note the exact tool name; github_check_runs_list does not exist.

DM engineers instead of broadcasting. slack_send_message accepts a user ID in the channel field, so resolving a GitHub login to a Slack user through slack_lookup_user_by_email turns the digest into a per-engineer DM. That tool needs users:read.email and cannot be called by custom bot users, so check your scope choice first.

Ask before writing. Creating issues and posting messages are write operations. Putting an approval step in front of them is the human-in-the-loop tool calling pattern, and it is the right default when an agent acts on a shared backlog.

Swap or add connectors. GitLab, Jira, and Notion follow the same call shape. Configure a connection, add an identifier, call the tool. Linear also exposes linear_graphql_query as an escape hatch for anything the 62 typed tools do not cover. The auto-release-notes agent and the daily standup agent are the closest neighbours if you want to see the same pattern with different endpoints.

The full code is on GitHub. Clone the repo, configure your connections, and have it running in under 20 minutes.

FAQ

Why three identifiers instead of one user ID everywhere?

Because the correct identity is different per connector. GitHub and Linear should act as the engineer, so PR visibility is bounded by that person's access and filed issues carry their name. Slack posts to a shared channel, where the app's own identity is correct. Keeping them separate lets you make that choice per service rather than accepting one answer for all three.

Does Scalekit refresh tokens, or do I handle that?

Scalekit refreshes them. The agent has no refresh logic, and execute_tool works identically on day 1 and day 180. What the agent does handle is revocation, which refresh cannot fix: on invalid_auth, token_expired, token_revoked, or not_authed, it generates a fresh authorization link straight into the logs.

Will restarting the poller create duplicate Linear issues?

No. The link map in state/pr_linear_links.json persists across restarts and is checked before every create. The one gap is running two instances against the same repo, since both read the same local file and can race. Move the map to a shared store with a unique constraint on the key before you scale out.

Can this agent post its own CI check onto a pull request?

Not through this connector. Creating check runs requires a GitHub App; OAuth apps and authenticated users cannot do it. The agent can read check state with github_check_runs_list_for_ref and report it, which covers the digest use case, but writing a status back to the PR is a different integration.

How do I run this across several repositories?

Run one process per repo with its own .env, which is the simplest path and keeps state files independent. If you consolidate into one process, watch the GitHub rate limit: 5,000 authenticated requests per hour is generous for one repo at 30-second intervals and gets tight past a couple of dozen. Widening POLL_INTERVAL buys more headroom than any other change.

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.