{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Yurii Serhiichuk — Blog",
  "home_page_url": "https://serhiichuk.dev/",
  "feed_url": "https://serhiichuk.dev/blog/feed.json",
  "description": "Articles on cloud architecture, DevOps, FinOps, and AI engineering.",
  "authors": [
    {
      "name": "Yurii Serhiichuk",
      "url": "https://serhiichuk.dev/"
    }
  ],
  "language": "en",
  "items": [
    {
      "id": "https://serhiichuk.dev/blog/llms-txt-v2-what-changed/",
      "url": "https://serhiichuk.dev/blog/llms-txt-v2-what-changed/",
      "title": "Your llms.txt guide is out of date: what v2 changed",
      "summary": "llmstxt.org published v2 in August 2026. Two things the popular guides tell you are no longer true, and the spec moved toward what Astro said on its way out.",
      "content_text": "_TL;DR: llms.txt v2 landed on 10 August 2026, and two of the things the guides taught you about the format are no longer true._\n\n## The spec moved and nobody said anything\n\nSo you added an llms.txt at some point, most likely from a guide, and it's been sitting at the root of your site ever since doing whatever it is that it does. [llmstxt.org](https://llmstxt.org/) now reads \"The /llms.txt file, v2\" and carries a modified date of 10 August 2026. The whole v1 to v2 diff lives on a separate [changes page](https://llmstxt.org/changes.html), which the spec links exactly once, in a single sentence in the Background, and that's the only pointer to it you're going to get.\n\nWhich means every guide written before that date describes v1 as current, because it was. That's not a criticism of the guides. The problem is the shape of this particular topic: you write your llms.txt once, the article that told you how to write it keeps ranking, and nothing anywhere tells a reader that the ground moved under it.\n\nI searched for how to write an llms.txt file while writing this, eleven days after v2 landed, and nothing on the first page of results mentions v2. Several of them carry 2026 in the title and were written this year. One from [6 May](https://www.2pointagency.com/blog/how-to-write-llms-txt/) still tells you the `Optional` block is \"reserved for lower-priority content the parser can safely skip when context windows are tight\". That is the first of the two things that stopped being true.\n\n## Two things you were told that are no longer true\n\n### The Optional section does not mean what you think\n\nv1 came with a tool called `llms_txt2ctx` that expanded an llms.txt file into a context for a model, and the `Optional` section was an instruction to that tool: these are the links you drop when the context has to be shorter. That's where \"put your secondary links under Optional and agents will skip them\" comes from. The tool is no longer part of the proposal, and the meaning left with it.\n\nVerbatim from the changes page:\n\n> The context-expansion tooling is no longer part of the proposal, and with it\n> goes the special meaning of the `Optional` section, which told those tools\n> what to omit. Optional sections are still allowed, and remain a useful\n> convention for secondary links, but they no longer carry mechanical\n> semantics.\n\nSo if you structured your file around Optional because a guide told you agents skip it, they never did. The skipping was one local tool's behaviour and never a promise any agent made, and now it's not in the spec either. It's a name for a section, a useful one that the spec still recommends, but only a name.\n\nIn my opinion this is barely a loss and mostly a correction. A mechanical `Optional` was only ever real inside `llms_txt2ctx`, and I've never seen an agent read that heading and act on it, so v2 removing the semantics changes what your file means on paper and nothing about what happens to it in practice. The thing left over is worth saying out loud though: there is now no way at all to tell an agent \"this part is skippable\", and that was a genuinely useful thing to be able to say.\n\n### \"Zero or more\" means zero\n\nv2's Format section lists the parts of the file, and the H2 file lists are \"Zero or more markdown sections delimited by H2 headers\". The H1 is the one thing that has to be there, and the spec says so directly: it is \"the only required section\". So a file that is an H1, a blockquote summary and a paragraph of context, with no H2 anywhere in it, is a conformant llms.txt.\n\nPlenty of tooling reports that as a spec violation. Mine did. The validator I put up on 12 August, two days after v2 landed, flagged \"No H2 sections\" with its basis set to `spec`, and in the same run it told you the `Optional` section was reserved by the spec for secondary links. Both of those were wrong on the day they shipped, and both were fixed on the 16th. A file with no H2 sections is still a file an agent can do nothing with, so it's worth a warning, but the warning is about practice and not about the spec, and labelling it wrong is how you get people restructuring files that were fine.\n\n## What v2 actually adds\n\n**Link relations for discovery.** This is the one the changes page says people asked for most. Given a page, how does a client find its markdown version, or the llms.txt that covers it, without guessing at URLs? v2 answers with standard relations:\n\n```\nLink: </docs/page.html.md>; rel=\"alternate\"; type=\"text/markdown\", </docs/llms.txt>; rel=\"describedby\"\n```\n\n`rel=\"alternate\" type=\"text/markdown\"` points at a page's markdown version and `rel=\"describedby\"` points at the llms.txt file that covers it. The clause worth reading twice is that these \"can be provided as HTML `<link>` elements, or as an HTTP `Link:` response header. The header form also works for non-HTML resources, such as the markdown files themselves, and can be added in web server or CDN configuration without modifying any pages.\" So if you run a docs site behind a CDN you can ship the whole discovery half of v2 without touching a single page or redeploying anything.\n\n**Both markdown-twin URL forms are legal.** v1 allowed only `page.html.md`, appending `.md` to the full page URL. v2 also allows `page.md` with the extension replaced, plus `index.html.md` or `index.md` for URLs without a filename. Publishing tools were already emitting the second form, and the spec blessed it rather than fighting it.\n\n**Subpath files finally mean something.** v1 let you put an llms.txt at any path and said nothing about what that path implied. v2 does:\n\n> A file covers the URLs under its path, and where more than one file applies,\n> agents should use the most specific one.\n\nThat's also what lets you take part when you control a path and not the origin root. A GitHub Pages project site can never write into the host's `/.well-known/`, but it can put an llms.txt in its own directory and have that mean something.\n\nWorth being precise about what that rule is and is not. It is not a search: an agent does not walk up the tree asking for files until one answers. Every file whose path is a prefix of the page's URL covers that page, and where several do, the longest path wins:\n\n```mermaid\nflowchart TB\n    page[\"A page at<br/>/workers/runtime-apis/\"]\n    other[\"A page at<br/>/r2/buckets/\"]\n    root[\"/llms.txt<br/>covers every URL on the host\"]\n    workers[\"/workers/llms.txt<br/>covers /workers/ and below\"]\n\n    root -. \"applies, but is less specific\" .-> page\n    workers == \"applies and is the most specific — an agent uses this one\" ==> page\n    root == \"the only file that applies\" ==> other\n\n    classDef file fill:#dbe7fe,stroke:#2563eb,stroke-width:1px,color:#0f172a\n    classDef url fill:#f2f5fa,stroke:#94a3b8,stroke-width:1px,color:#0f172a\n    class root,workers file\n    class page,other url\n```\n\nBoth files genuinely cover `/workers/runtime-apis/`. The root one does not stop covering it because a deeper file exists — it is still the file that describes the rest of the host, and the deeper file wins only for the URLs beneath its own path.\n\n## Who actually does this\n\nI checked these with curl on 21 August 2026:\n\n| Site | Root | Subpath |\n| --- | --- | --- |\n| developers.cloudflare.com | 200, 15,906 bytes | `/workers/llms.txt` 200, 82,671 bytes |\n| docs.stripe.com | 200, 89,857 bytes | — |\n| docs.astro.build | 404 | — |\n\nCloudflare is the one worth dwelling on. **15,906 bytes** at the root and **82,671 bytes** under `/workers/` is the subpath rule doing its job in production: a small index for the whole developer site, and a big section-specific file for the part a coding agent is most likely to be deep inside already. Ask about Workers, get the Workers file, and the root file still covers everything the deeper one does not.\n\nHere's what that pair looks like from the inside, checked from the page itself:\n\n:::full\n![The llms.txt checker open beside developers.cloudflare.com/workers/, reporting that one less specific llms.txt file sits above this one and offering to check /llms.txt as well](./extension-subpath-cloudflare.png)\n:::\n\nThe note tagged `spec` is the v2 rule stated back at you: the file at `/workers/` covers the URLs under `/workers/`, the root file covers everything, and an agent standing on this page takes the more specific one. Which file wins is a question you can only ask from a page, because the answer depends on which page you are standing on.\n\nOne curiosity while you're in there: llmstxt.org serves its own `/llms.txt` as `text/plain`. The spec says nothing about content types so nothing is broken, but if your checker treats anything other than `text/markdown` as a failure, it's going to fail the spec's own file.\n\n## Astro removed theirs\n\nAstro deleted its files in [withastro/docs#13538](https://github.com/withastro/docs/pull/13538), titled \"[i18nIgnore] Remove `llms.txt` files\", merged on 20 April 2026. `llms-full.txt` and `llms-small.txt` went, and `docs.astro.build/llms.txt` is a 404 today as well. The stated reason is the one that should worry you if you maintain a docs site:\n\n> we've seen little uptake recently in usage — these files get very little traffic\n\nThey chose to put the effort into their [MCP server](https://github.com/withastro/docs-mcp) instead, \"and perhaps in the future offer per-page Markdown content\", then signed off with \"AI trends move quickly\". The PR also shows what the files were costing them, flagged as \"anecdotal obviously\": two Netlify builds, 5m 6s with the files and 4m 22s without.\n\nTwo dates matter here. The removal is April and v2 is August, so what Astro measured was v1, and v1 gave an agent no way to find the file other than guessing the URL. Discovery is the exact hole v2 spends most of its new text filling. So I wouldn't read \"very little traffic\" as a verdict on the idea. I read it as a verdict on a file nobody could find.\n\nThat half-sentence about per-page Markdown aged strangely well. Astro walked out in April saying what they'd rather have is Markdown per page. Four months later the headline additions in v2 are markdown twins per page, plus the link relations that make them findable.\n\nWhat v2 still doesn't do, and I'm not sure a text file at a well-known path ever can, is give an agent a reason to prefer your llms.txt over just fetching the page. Astro's answer to that was MCP, where the agent is handed the tool rather than having to go looking for a file, and that's a stronger position than a filename convention has ever been. Both things can be true: v2 is a real improvement, and the harder question is still open.\n\n## What to do about it\n\nNone of this takes long. If your file has an Optional section that you picked for mechanical reasons, keep the name if you like it but stop expecting it to do anything. If your pages have markdown twins, add the relations: one `<link rel=\"describedby\">` and one `<link rel=\"alternate\" type=\"text/markdown\">`, or the same thing as a `Link:` header at the CDN if you'd rather not go near the templates. serhiichuk.dev emits the `describedby` one on every page. And if your docs live under a path, an llms.txt at that path now has a defined meaning, so a large section-specific file sitting next to a small root one is a shape the spec understands rather than a shape you're getting away with.\n\nWhat none of it buys you is search ranking. No engine has said it uses llms.txt for anything, and anyone who tells you otherwise is guessing.\n\nI keep a checker for all of this at [serhiichuk.dev/tools/llms-txt](https://serhiichuk.dev/tools/llms-txt/) — it validates against v2 and labels every finding as spec, convention or hygiene, so you can decide which ones you care about. There's [a Chrome extension](https://serhiichuk.dev/tools/llms-txt/#extension) as well, which runs the same rules against the tab you're on. That one exists because half of v2 is discovery, and the link relations live on the page rather than in the file — so a checker that only ever fetches a URL is looking in the wrong place for them. It also reaches what a hosted checker cannot: a staging host, an intranet address, anything that is not on the open internet.",
      "date_published": "2026-08-21T00:00:00.000Z",
      "tags": [
        "llms.txt",
        "AI",
        "Agents",
        "AEO",
        "Documentation"
      ],
      "image": "https://serhiichuk.dev/_astro/og.BsRdcnqD.png"
    },
    {
      "id": "https://serhiichuk.dev/blog/building-an-sre-agent-with-adk-and-antigravity/",
      "url": "https://serhiichuk.dev/blog/building-an-sre-agent-with-adk-and-antigravity/",
      "title": "Building an Autonomous SRE Agent with Google ADK and the Antigravity SDK",
      "summary": "A runnable autonomous SRE agent: Google ADK for multi-agent diagnosis, the Antigravity SDK for a deny-by-default runtime. Runs locally, zero GCP credentials.",
      "content_text": "One of the most stressful parts of being an on-call engineer is triaging a production incident in\nthe middle of the night. Modern distributed systems amplify the pain with the extra cognitive\noverload of, well, the distributed systems: logs scattered across dozens of microservices, deeply\nnested trace paths, and half a dozen observability dashboards you have to correlate by hand.\n\nUsually the setup makes total sense for the SREs who built it (maybe it's the cost, maybe it's\nwhichever tool the team knows best, maybe it's just optimizing for one particular concern), but it\npiles a lot of load onto whoever is on call.\n\nSo in the era of AI, it makes sense to get some help from an autonomous, smart system that's\nfine-tuned for your setup and remembers all the little bits and pieces specific to your\ninfrastructure.\n\nThis post is a complete, runnable blueprint for exactly that: an autonomous SRE agent on Google\nCloud you can host next to your main stack. I'm combining two Google frameworks that solve two very\ndifferent problems:\n\n- the [**Agent Development Kit (ADK)**](https://adk.dev/) for the multi-agent diagnostic _reasoning_, and\n- the [**Google Antigravity SDK**](https://antigravity.google/product/antigravity-sdk) for the agent _runtime_ — tool wiring, deny-by-default safety\n  policies, and local simulation.\n\nThe whole stack runs locally with **zero GCP credentials** thanks to a mock-telemetry mode, so you\ncan try it in under a minute. And everything below is verified against the code in the repo, no\nhand-waving.\n\n---\n\n## The Core Architecture: Reasoning + Safety\n\nA proper SRE assistant has to get two things right at the same time: **reasoning orchestration**\n(which diagnostic step happens when) and **environmental safety** (the agent must never be able to\nmutate production while it pokes around).\n\nThe blueprint splits those concerns across four small FastAPI services that talk to each other over\nan [Agent-to-Agent (A2A) protocol](https://a2a-protocol.org/latest/), with results streamed back as\n[Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).\n\n```mermaid\nflowchart TB\n    User([\"👤 On-call engineer\"])\n    Orch[\"🛡️ Orchestrator · Cloud Run: sre-agent<br/>Antigravity runtime<br/>policy = deny('*'), allow('diagnose_sre')<br/>(sandboxed — its only move is to delegate)\"]\n    SRE[\"🔬 SRE sub-agent · Cloud Run: sre-sub-agent<br/>SSE endpoint /v1/agents/sre/messages<br/>ADK workflow: TraceAnalyzer ➜ LogCorrelator\"]\n    Inv[\"📚 Inventory agent · inventory-agent\"]\n    FS[(\"Firestore<br/>topology cache + sessions\")]\n    Obs[(\"☁️ Cloud Trace · Logging · Monitoring\")]\n    App[\"🐒 Target app 'chaos monkey' · sre-chaos-monkey\"]\n\n    User -->|\" GET /chat · POST prompt (SSE) \"| Orch\n    Orch -->|\" diagnose_sre — A2A HTTP + SSE \"| SRE\n    SRE -->|\" streamed Markdown report \"| Orch\n    Orch -->|\" A2UI render + 📥 download button \"| User\n    SRE -->|\" GET topology (A2A) \"| Inv\n    Inv --> FS\n    SRE -->|\" read-only queries \"| Obs\n    App -->|\" write-only: spans + logs \"| Obs\n```\n\nI'm using the Antigravity SDK for the Orchestrator, the front-facing agent. It gives you solid\nsafety gates out of the box and is smart enough to handle the user's requests directly. Its only\nreal capability is to delegate to the read-only SRE sub-agent, and that's enforced by a\ndeny-by-default policy:\n\n```python\nsafety_policies = [\n    deny(\"*\"),            # nothing is allowed by default\n    allow(\"diagnose_sre\") # …except delegating to the SRE sub-agent\n]\n```\n\nThe config above basically gates whatever tools we want to allow for the agent to use.\n\nThe actual diagnostic work is done by Google ADK agents. ADK is a code-first library for\nmulti-agent graphs, and the SRE sub-agent runs a two-node graph:\n\n- **TraceAnalyzer** scans recent trace summaries, filters for transactions that errored or breached\n  the latency budget (>5000 ms), and isolates the single failing `traceId`.\n- **LogCorrelator** pulls the spans and the logs tagged with that `traceId`, then reasons over them\n  with a small toolbelt (metric queries, cascade analysis, post-mortem generation) to produce the\n  root-cause report.\n\nThere's also a third agent: an inventory agent that gives the SRE aggregated knowledge of the\nresources available in the GCP project. This grounds the diagnosis and keeps the agent focused on\nthe actual resources instead of wandering around too much.\n\n> **Runs anywhere, credentials optional.** Every cloud dependency (`google-adk`, `google-antigravity`,\n`google-cloud-*`, `opentelemetry`) is imported behind a `try/except ImportError` with a mock\nfallback, and a `MOCK_GCP` flag swaps live API calls for local JSON fixtures. Same code path for the\nlocal simulation and the Cloud Run deployment.\n\n---\n\n## Anatomy of a Diagnosis\n\nWhen an alert fires, here's what actually happens end to end, from the on-call prompt to a finished\npost-mortem. One thing worth calling out is the **two-tier reasoning**: with a real model key the\nfull ADK graph runs; offline it falls back to a deterministic simulated workflow that produces an\nidentically-structured report. So the demo behaves the same whether or not you have a Gemini key.\n\n```mermaid\nsequenceDiagram\n    autonumber\n    actor U as Engineer\n    participant O as Orchestrator (sre-agent)\n    participant S as SRE sub-agent (sre-sub-agent)\n    participant I as Inventory agent\n    participant T as Trace/Log/Metric tools\n    participant M as Gemini (ADK)\n    U ->> O: \"Diagnose the latency spikes and write a post-mortem\"\n    O ->> S: diagnose_sre() · A2A POST /v1/agents/sre/messages\n    S -->> O: SSE: \"🔧 fetching topology…\"\n    S ->> I: GET project topology\n    I -->> S: services + databases (Firestore cache)\n    S ->> T: query_traces(limit=10)\n    T -->> S: recent trace summaries\n\n    alt HAS_ADK and GEMINI_API_KEY present\n        S ->> M: TraceAnalyzer → pick failing traceId\n        M -->> S: traceId\n        S ->> M: LogCorrelator → reason over spans + logs\n        M -->> S: root-cause narrative\n    else offline / no key\n        S ->> S: _run_simulated_diagnostics() (deterministic)\n    end\n\n    S ->> T: analyze_trace_cascade() + generate_post_mortem()\n    T -->> S: bottleneck table + post-mortem markdown\n    S -->> O: SSE chunks → final report\n    O -->> U: A2UI dashboard + 📥 Download post-mortem\n```\n\nThe Orchestrator does the one thing its policy allows: it hands the problem to the SRE sub-agent and\nsteps back. From there the sub-agent streams its progress back as Server-Sent Events, so the on-call\nengineer watches the investigation unfold live instead of staring at a spinner.\n\nIt starts by pulling the project topology (which services exist, how they call each other, and where\ntheir databases sit) from the Inventory agent's Firestore-backed cache. That map tells the diagnosis\nwhere to look. Then it fetches the recent traces and runs them through the two-node graph:\nTraceAnalyzer collapses thousands of spans down to the single failing trace worth investigating, and\nLogCorrelator pulls every span and log line sharing that trace's ID to name the actual root cause.\nNot just _\"the database was slow,\"_ but which span failed, with which error, and why.\n\nOnly then does it run the cascade analysis and draft the post-mortem, streaming the finished report\nback through the Orchestrator to the chat UI, where it lands as a rendered dashboard with a one-click\ndownload.\n\nThe nice thing about this setup is how extensible it is. Adding a new sub-agent or expanding an\nexisting tool is easy with ADK and the A2A protocol, so the blueprint grows with your infrastructure\ninstead of against it.\n\n---\n\n## Deep Dive: Cascade Latency & Bottleneck Analysis\n\nIn order to showcase the agent, I have also built another FastAPI service that emulates real\ndatabase errors with timeout exceptions and telemetry spans. As it is just a simulation, quite a lot\nof actual problems are quite similar to this one, and frequently the initial gateway/backend latency\nor errors are hidden downstream. But of course this is a textbook example still: a gateway request\nthat looks 10-second-slow, but where 99% of the time is actually trapped in a database call three\nlevels down.\n\n```mermaid\ngantt\n    title Trace b49d… — Gateway request timeline (ms)\n    dateFormat x\n    axisFormat %Lms\n    section /api/gateway\n        inclusive 10270ms (self 20ms): active, 0, 10270\n    section /api/backend\n        inclusive 10250ms (self 50ms): active, 10, 10260\n    section /api/database ⛔ timeout\n        inclusive 10200ms (self 10200ms): crit, 30, 10230\n```\n\nThe cascade-analysis tool builds the span parent/child map and computes, for every span:\n\n- **Inclusive duration** — wall-clock time of the span including its children.\n- **Exclusive (self) duration** — the active time spent _in that span alone_:\n\n  $$\\text{ExclusiveTime}(s) = \\text{InclusiveTime}(s) - \\sum_{c \\in \\text{children}(s)} \\text{InclusiveTime}(c)$$\n\nThe span with the largest exclusive time is the true bottleneck. Here's the actual, verified output\nfrom `uv run simulate_incident.py` (no edits, no GCP):\n\n```text\n### 🔍 Span Latency Breakdown\n| Service / Span Name | Span ID            | Parent ID         | Status | Inclusive Time | Exclusive (Self) Time | Contribution |\n| :---                | :---               | :---              | :---   | :---           | :---                  | :---         |\n| /api/gateway        | span-gateway-111   | None              | ERROR  | 10270 ms       | 20 ms                 | 0.2%         |\n|   └── /api/backend  | span-backend-222   | span-gateway-111  | ERROR  | 10250 ms       | 50 ms                 | 0.5%         |\n|       └── /api/database | span-database-333 | span-backend-222 | ERROR | 10200 ms       | 10200 ms              | 99.3%        |\n\n### 🚨 Identified Bottleneck\n*   Bottleneck Span:     /api/database (span-database-333)\n*   Self-Execution Time: 10200 ms (99.3% of total trace)\n*   Status:              ERROR\n*   Error Message:       ConnectionTimeoutError: Failed to connect to db-primary.gcp.internal:5432 after 10000ms\n```\n\nThe gateway and backend both look \"slow\" at 10 s inclusive, but their _self_ time is a rounding\nerror. The agent ignores the noise and points straight at `/api/database`: 99.3% of the budget,\nburned in a single connection timeout.\n\n---\n\n## Automated Post-Mortem & One-Click Export\n\nDiagnosis is only half the job. The deliverable on-call engineers actually need is a post-mortem.\nAfter the cascade analysis, a post-mortem generator drafts a complete Markdown document with an\nIncident Overview (time, root service, Trace ID, impact duration), a Timeline, a Root Cause\nAnalysis, and a Prevention Plan, all populated from the real trace and log data.\n\nThat Markdown is then rendered through the [A2UI protocol](https://a2ui.org/):\n\n```mermaid\nflowchart TB\n    R[\"SRE report markdown<br/># 🚨 Incident Post-Mortem\"] --> T[\"translate_markdown_to_a2ui()\"]\n    T --> C[\"A2UI components:<br/>alert · preview · download_button\"]\n    C --> B[\"Web chat renders<br/>.download-pm-btn\"]\n    B --> D[\"📥 Client-side Blob download<br/>post_mortem.md\"]\n```\n\nA server-side translator spots the post-mortem heading and appends a download-button component; the\nbrowser renders it as a styled button that builds the file entirely client-side (no server\nround-trip) from the Markdown it already holds.\n\nOne click exports `post_mortem.md`, ready to drop into your incident-review wiki.\n\nAnd here's how it actually looks in the deployed chat UI:\n\n![SRE agent chat UI](./sre-agent-ui-view.png)\n\n---\n\n## Least-Privilege IAM on Cloud Run\n\nHanding an autonomous agent unrestricted cloud access is a non-starter. The deploy pipeline gives\neach Cloud Run service its **own service account** with the narrowest role set I could get away with:\n\n```mermaid\nflowchart TB\n    subgraph W[\"✍️ Write-only — emits telemetry\"]\n        direction LR\n        A[\"sre-chaos-monkey-sa<br/>(target app)\"] --- AR[\"roles/cloudtrace.agent<br/>roles/logging.logWriter\"]\n    end\n    O[(\"Cloud Trace /<br/>Logging /<br/>Monitoring\")]\n    subgraph R[\"👁️ Read-only — consumes telemetry\"]\n        direction LR\n        S[\"sre-agent-sa<br/>(SRE diagnostics)\"] --- SR[\"roles/cloudtrace.user<br/>roles/logging.viewer<br/>roles/monitoring.viewer<br/>roles/datastore.user\"]\n    end\n    A -. \"spans + logs\" .-> O\n    O -. \"queries\" .-> S\n```\n\nThe split is the whole point: the app that _generates_ the chaos can only ever **write** telemetry,\nand the agent that _investigates_ it can only ever **read**. Neither can act on the other's plane.\n(The deploy also provisions an `inventory-agent-sa` for topology discovery and a dedicated\n`sre-build-sa` for Cloud Build, each scoped just as tightly.)\n\n---\n\n## Try It Yourself in 60 Seconds\n\nThe whole scan → correlate → analyze → post-mortem loop runs locally, with **no GCP account or\ncredentials required.**\n\n```bash\n# 1. Clone the repo\ngit clone https://github.com/xSAVIKx/sre-agent.git\ncd sre-agent\n\n# 2. Install uv and sync the workspace (app, agent, sre_agent, inventory_agent, sre_common)\npip install uv\nuv sync --all-packages\n\n# 3. Run the incident simulation\nuv run simulate_incident.py\n```\n\nThe simulation triggers a database-timeout incident in the target app, writes mock traces and logs\nto `mock_telemetry_data/`, boots the Orchestrator in mock mode, runs the diagnostic workflow\nin-process, and prints the report to your terminal. You'll see the structured telemetry logs (gateway\n→ backend → database, ending in a `CRITICAL ConnectionTimeoutError`) followed by the full diagnosis:\nthe **99.3% `/api/database` bottleneck table** and the complete `# 🚨 Incident Post-Mortem` shown\nabove.\n\nWant the full multi-service experience with the web chat UI? `docker-compose up --build` brings up\nthe Orchestrator, SRE agent, Inventory agent, a Firestore emulator, and the target app together, then\nyou open the chat at `/chat`.\n\n---\n\n## Project Resources\n\nThe complete, runnable source, plus a step-by-step [**CODELAB**](https://github.com/xSAVIKx/sre-agent/blob/master/CODELAB.md) that builds this from\nscratch, lives\non GitHub:\n\n**👉 [github.com/xSAVIKx/sre-agent](https://github.com/xSAVIKx/sre-agent)**",
      "date_published": "2026-07-09T00:00:00.000Z",
      "tags": [
        "SRE",
        "AI Agents",
        "Google Cloud",
        "Google ADK",
        "DevOps",
        "Observability"
      ],
      "image": "https://serhiichuk.dev/_astro/og.DyRRkI16.png"
    },
    {
      "id": "https://serhiichuk.dev/blog/open-knowledge-format-portable-digital-map-of-your-data-as-code/",
      "url": "https://serhiichuk.dev/blog/open-knowledge-format-portable-digital-map-of-your-data-as-code/",
      "title": "Open Knowledge Format — portable digital map of your data as code",
      "summary": "Google's Open Knowledge Format turns data knowledge into portable markdown. I built OKF Skills — six vendor-neutral connectors, a visualizer, and an MCP server.",
      "content_text": "Yesterday Google dropped an [article](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing) describing an interesting idea — a portable format for knowledge sharing as markdown. This is not new or unique (LLM-wiki, Obsidian vaults and even agents/claude.md are similar concepts). What stands out here is the openness of the proposed standard (also, just a v0.1 draft now) and actual ability of Google to make it a de-facto standard.\n\n![OKF announcement on the Google Cloud blog](./okf-google-blog.png)\n\n---\n\nI've spent quite some time as GDE and AI integrator helping teams wire data into the LLM agents, and I keep hitting the same wall. The model is brilliant and the data is right there, but the **knowledge about the data** — what a table means, how a metric is defined or why a column was deprecated is, unfortunately, scattered across hundreds of different places. Something is available in company Google Docs or Drive, the other things are available directly in readme and the extra missing piece is actually in that one engineer's head. Unfortunately, we can't write a connector for the last piece, but as Sam McVeety and Amir Hormati highlight in the blog post, we can at least help the agents (and developers) navigate through such knowledge better by introducing some shared and well known way of sharing the knowledge. And don't get me wrong, almost every team has probably already solved this in one proprietary way or another, rebuilding that same plumbing again and again from scratch.\n\nOKF's wager against that mess is almost provocatively simple. If you can `cat` a file, you can read it. If you can `git clone` a repository, you can ship it. The spec only enforces one field to be explicitly provided — `type`, and that's exactly the trick: standardize only the smallest possible interoperability surface while leaving everything else to the people producing the data.\n\n---\n\nThe reference repository indeed provides only the bare minimum — a pretty simple [spec](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/4c40ef103cd7dd9ca978bbb190fc795cfde4f7c3/okf/SPEC.md), example agent (which is BigQuery-oriented and pretty opinionated) and some sample bundles. And while this is a great starter pack, the idea of OKF, as I see it, is that knowledge lives _everywhere_. My SQLite file has knowledge. My Postgres instance has knowledge. My `~/notes` folder and my Git history as well. If OKF is going to be a lingua franca, it needs producers for the messy real data most of us actually work with, and it needs consumers that are not tied to any one vendor's agent.\n\nSo I built that: the [OKF Skills](https://github.com/xSAVIKx/okf-skills), a vendor-neutral fleet of producers and consumers for OKF.\n\n:::full\n![okf-viz three-pane bundle explorer](./okf-viz.png)\n:::\n\n---\n\nThe idea is simple: any source in, any agent out.\n\nWhat I built already is just a small baseline that I hope will be useful moving forward. That's six connectors: SQLite, MySQL, PostgreSQL, BigQuery, the local file system and Git. Each is a single portable Go binary with the same three commands: `produce` a bundle from the source, `ingest` a bundle to verify it (or `--sync` curated descriptions back to the source), and `schema` to describe itself. The SQL connectors can embed a per-column data profile and sample rows right in the concept docs.\n\nThen three guidance skills with zero extra runtime. `reader` teaches an agent how to traverse any OKF bundle cheaply (index first, frontmatter only, grep before you read). The `enricher` teaches your harness to write good descriptions grounded in the schema, profile, and samples. And finally the `producer-generator` to bootstrap other connectors real fast.\n\nI also decided to rebuild the visualizer Google team embedded into their agent. The `viz` renders any bundle into a single self-contained `index.html` near your OKF data. It produces a three-pane explorer with an interactive graph, navigator and content reader. Now there's no need to reach for the Google-provided agent just to create a visualization if you just want that.\n\nFinally, there's the MCP server that is capable of discovering every installed connector and exposes them as relevant tools. Point Claude Code or Antigravity at it and you're ready to rock. You can install all of them today with:\n\n```bash\nnpx skills add xSAVIKx/okf-skills\n```\n\nNow you should be able to just ask your harness nicely to create, enrich and visualize the OKF bundle.\n\n---\n\nI hope this agentic tooling will allow the community to embrace the new format and truly make it a widely used standard. And I warmly welcome any new contributions to the existing connectors.\n\nUseful links:\n* [Introducing the Open Knowledge Format](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing)\n* [OKF GitHub repo](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf)\n* [OKF-skills repo](https://github.com/xSAVIKx/okf-skills)\n* [OKF-skills visualization demo](https://xsavikx.github.io/okf-skills)",
      "date_published": "2026-06-15T00:00:00.000Z",
      "date_modified": "2026-07-28T00:00:00.000Z",
      "tags": [
        "Open Knowledge Format",
        "AI Agents",
        "Google Cloud",
        "Data Engineering",
        "MCP"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.BY20-BMx_S4f41.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/cloud-workstations-building-reusable-development-environments-in-cloud-part-2/",
      "url": "https://serhiichuk.dev/blog/cloud-workstations-building-reusable-development-environments-in-cloud-part-2/",
      "title": "Cloud Workstations: building reusable development environments in cloud — Part 2",
      "summary": "How to customize your Cloud Workstations environment and improve developer productivity: custom base images with Code OSS and WebStorm, built by Pulumi.",
      "content_text": "How to customize your Cloud Workstations environment and improve developer productivity.\n\nIn the first part we reviewed the pros and cons of local vs cloud development environments and\nchecked how to create a minimal yet fully functional setup of the Google Cloud Workstations using\nPulumi.\n\n**[Cloud Workstations: building reusable development environments in cloud](/blog/cloud-workstations-building-reusable-development-environments-in-cloud/)** —\n*Setting up Cloud Workstations using Pulumi, TypeScript and Bun*\n\nNow it's time to make that development environment yours and see how one can customize base images\nand ensure developers have all the tools they need.\n\n## Defining a customized base image\n\nGCP provides a set of base images available out of the box that provide a set of pre-configured\nIDEs — named Code OSS and IDEs from the JetBrains toolbox (IDEA, PyCharm, WebStorm, etc.).\n\n**[Preconfigured IDEs | Cloud Workstations | Google Cloud Documentation](https://docs.cloud.google.com/workstations/docs/preconfigured-ides)** —\n*Preconfigured IDEs for use with Cloud Workstations*\n\nYou can check out the complete list of the IDEs and their respective docker images\n[here](https://docs.cloud.google.com/workstations/docs/preconfigured-base-images).\n\nCode OSS provides a ready-to-use web UI while JetBrains IDEs expose a remote development environment\ngateway to which you can connect from your local copy of the IDE. Both approaches have their pros\nand cons but why don't we just combine them together?\n\nTo create our customized base image for the workstation instance we will use Code OSS and JetBrains\nWebStorm base images together to provide an easy-to-start IDE with terminal and file explorer in\nyour web browser with Code OSS and also expose JetBrains gateway to\n[connect your local IDE](https://docs.cloud.google.com/workstations/docs/develop-code-using-local-jetbrains-ides).\n\n### Available extension points\n\nThe core part of the workstations base images setup is distinction between system and user space.\nWhile workstations work with a containerized environment, your IDE is actually running inside a\ncontainer with a volume mounted in it to e.g. your `/home` directory. It means that when you install\nsome tooling or perform container configuration, you should account for that as well.\n\nDepending on the tooling you are going to install and whether you want to maintain the ability to\nupdate it and configure it from within your user space, you may want to move some tooling\ninstallation scripts from the Docker image setup to user space scripts.\n\nOn startup, base images run files under `/etc/workstation-startup.d/*` in lexicographical order to\ninitialize the workstation environment. There is a special script in that folder called\n`030_customize_environment.sh` that executes `/home/user/.workstation/customize_environment` as\n`user`.\n\nYou can unwrap the complete entrypoint setup by examining `/google/scripts/entrypoint.sh` in the\nbase images.\n\n### Base image Dockerfile\n\nThe absolutely minimal customized setup with Code OSS and WebStorm looks like this:\n\n```dockerfile\nFROM us-central1-docker.pkg.dev/cloud-workstations-images/predefined/code-oss:latest AS code-oss-image\n\nFROM us-central1-docker.pkg.dev/cloud-workstations-images/predefined/webstorm:latest AS runtime\n\nCOPY --from=code-oss-image /opt/code-oss /opt/code-oss\nCOPY --from=code-oss-image /etc/workstation-startup.d/110_start-code-oss.sh /etc/workstation-startup.d/110_start-code-oss.sh\n```\n\nNow you can sprinkle it with some standard tooling, e.g. add `mysql` and `redis` clients, maybe `gh`\nand `lefthook` CLIs — these are the ones that we just want to bundle into the image.\n\n```dockerfile\nRUN apt update \\\n    && DEBIAN_FRONTEND=noninteractive apt install apt-utils software-properties-common unzip -fyq \\\n    && apt upgrade -fyq \\\n    # Install MySQL client \\\n    && DEBIAN_FRONTEND=noninteractive apt install mariadb-client -yq \\\n    # Install Redis Client \\\n    && curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg \\\n    && chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg \\\n    && echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | tee /etc/apt/sources.list.d/redis.list \\\n    && apt update \\\n    && DEBIAN_FRONTEND=noninteractive apt install redis -yq \\\n    # Install GitHub CLI \\\n    && mkdir -p -m 755 /etc/apt/keyrings \\\n    && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n    && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \\\n    && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \\\n    && mkdir -p -m 755 /etc/apt/sources.list.d \\\n    && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \\\n    && apt update \\\n    && DEBIAN_FRONTEND=noninteractive apt install gh -yq \\\n    # Install lefthook \\\n    && curl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.deb.sh' | sudo -E bash \\\n    && DEBIAN_FRONTEND=noninteractive apt install lefthook -yq\n```\n\nThe next step is to add some pre-configured IDE extensions. This is probably specific to your team's\nneeds. JetBrains plugins can be installed as follows:\n\n```dockerfile\n# Install .env - https://plugins.jetbrains.com/plugin/9525--env-files\nRUN bash /installer-scripts/plugin-installer.sh \\\n    -d /opt/WebStorm/plugins/ \\\n    9525 \\\n    # Install .ignore - https://plugins.jetbrains.com/plugin/7495--ignore\n    && bash /installer-scripts/plugin-installer.sh \\\n    -d /opt/WebStorm/plugins/ \\\n    7495 \\\n    # Install google cloud code - https://plugins.jetbrains.com/plugin/8079-google-cloud-code\n    && bash /installer-scripts/plugin-installer.sh \\\n    -d /opt/WebStorm/plugins/ \\\n    8079 \\\n    # Install Gemini Code Assist - https://plugins.jetbrains.com/plugin/24198-gemini-code-assist\n    && bash /installer-scripts/plugin-installer.sh \\\n    -d /opt/WebStorm/plugins/ \\\n    24198 \\\n    # Install JetBrains AI Assistant - https://plugins.jetbrains.com/plugin/22282-jetbrains-ai-assistant\n    && bash /installer-scripts/plugin-installer.sh \\\n    -d /opt/WebStorm/plugins/ \\\n    22282 \\\n    # Install JetBrains Junie - https://plugins.jetbrains.com/plugin/26104-jetbrains-junie\n    && bash /installer-scripts/plugin-installer.sh \\\n    -d /opt/WebStorm/plugins/ \\\n    26104\n```\n\nThe logic is simple here: you open up the plugin page (e.g.\n<https://plugins.jetbrains.com/plugin/26104-jetbrains-junie>), copy the ID that goes after `/plugin`,\nand install it with the `plugin-installer.sh` script.\n\nFor Code OSS we can either\n[download extensions manually during the build phase](https://docs.cloud.google.com/workstations/docs/customize-container-images#container-image-that-pre-installs-ide-extensions-in-code-oss-for-cloud-workstations-for-java-development)\nor prepare a user-space script and use `/opt/code-oss/bin/codeoss-cloudworkstations --install-extension`.\nLet's prepare a user-space script `120_install_vs_code_extensions.sh`:\n\n```bash\n#!/usr/bin/env bash\n\nsudo -u user /opt/code-oss/bin/codeoss-cloudworkstations \\\n  --install-extension ms-azuretools.vscode-containers \\\n  --install-extension pulumi.pulumi-vscode-tools  \\\n  --install-extension GoogleCloudTools.cloudcode \\\n  --install-extension Google.geminicodeassist \\\n  --install-extension cweijan.vscode-database-client2 \\\n  --install-extension cweijan.dbclient-jdbc \\\n  --install-extension RooVeterinaryInc.roo-cline \\\n  --install-extension redhat.vscode-yaml \\\n  --install-extension dbaeumer.vscode-eslint \\\n  --install-extension orta.vscode-jest \\\n  --install-extension gamunu.vscode-yarn\n```\n\nAnd then copy the script with:\n\n```dockerfile\nCOPY 120_install_vs_code_extensions.sh /etc/workstation-startup.d/\nRUN chmod +x /etc/workstation-startup.d/120_install_vs_code_extensions.sh\n```\n\n### customize_environment.sh\n\nAnother alternative solution is to create a `customize_environment` script in the user's home folder.\nLet's add `pulumi` and `nvm` to the setup. Those tools are usually installed into the user's home.\n\nLet's ensure the home directory will be available first and create `011_customize_user.sh`:\n\n```bash\n#!/usr/bin/env bash\n\nsudo mkdir -p /home/user/.workstation/\nsudo mkdir -p /home/user/.local/bin/\nsudo cp /tmp/customize_environment.sh /home/user/.workstation/customize_environment\n\nsudo chown -R user /home/user/\nsudo chmod +x /home/user/.workstation/customize_environment\n```\n\nNow let's create `customize_environment.sh` where we install Pulumi, NVM, some LTS node and Gemini\nCLI.\n\n```bash\n#!/usr/bin/env bash\n\nset -x;\nexport DEBIAN_FRONTEND=noninteractive\nexport PULUMI_VERSION=\"3.206.0\"\nexport NVM_VERSION=\"0.40.3\"\n\ncurl -fsSL https://get.pulumi.com | bash -s -- --version \"${PULUMI_VERSION}\"\n\n# This is required for some tools that source from local bin but ignore bashrc\nsudo ln -s ~/.pulumi/bin/pulumi ~/.local/bin\n\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash\nexport NVM_DIR=\"$([ -z \"${XDG_CONFIG_HOME-}\" ] && printf %s \"${HOME}/.nvm\" || printf %s \"${XDG_CONFIG_HOME}/nvm\")\"\n[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\" # This loads nvm\n\nnvm install --lts\nnpm install -g corepack @google/gemini-cli\n```\n\nNow we need to add those two scripts to the base image with:\n\n```dockerfile\nCOPY 011_customize_user.sh /etc/workstation-startup.d/\nCOPY customize_environment.sh /tmp/customize_environment.sh\nRUN chmod +x /etc/workstation-startup.d/011_customize_user.sh \\\n    && chmod +x /tmp/customize_environment.sh \\\n```\n\nWe're all set. We have explored three ways to customize a Cloud Workstation base image:\n\n- by extending the base image and installing tooling into the image itself\n- by providing workstation startup scripts\n- by creating a `customize_environment` script in the user's home folder\n\nYou can find a complete example of the WebStorm + Code OSS base image in the\n`xSAVIKx/gcp-cloud-workstations-howto` repository.\n\n**[gcp-cloud-workstations-howto/customized/base_images/webstorm at main](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/tree/main/customized/base_images/webstorm)** —\n*Example infrastructure setup for GCP Cloud Workstations using Pulumi*\n\n## Letting Pulumi do the job\n\nNow that we're all set from the base image perspective, we need to fine-tune our Pulumi\ninfrastructure setup.\n\nYou can jump to a complete setup and check it out\n[here](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/blob/main/customized/index.ts).\n\n### Artifact Registry setup\n\nWe will be using [Google Artifact Registry](https://docs.cloud.google.com/artifact-registry/docs) to\nstore our customized image as this simplifies authentication for the Workstations, keeps the image\nphysically closer to the virtual machine, as well as provides fine-grained control over the private\nimages.\n\nFirst, we need to enable Artifact Registry and legacy Container Registry services using the\n`enableServices` method we created before or just by extending the `requiredServices` array with:\n\n```typescript\n\"artifactregistry.googleapis.com\",\n\"container.googleapis.com\",\n```\n\nNow here's how you can define a Docker registry using Artifact Registry service:\n\n```typescript\nfunction defineArtifactRegistry() {\n  const artifactRegistry = new gcp.artifactregistry.Repository(\n    \"dockerRegistry\",\n    {\n      location: region,\n      repositoryId: \"containers\",\n      description: \"Private containers registry\",\n      format: \"DOCKER\",\n      dockerConfig: {\n        // usually better set to `true`, but for the lab we're setting it to false\n        // to ease re-creation of the same containers.\n        immutableTags: false,\n      },\n    },\n    { provider: gcpProvider, dependsOn: services },\n  );\n  return { artifactRegistry };\n}\n```\n\nFinally, we'll create a helper method to grab an authentication token to the registry as it's\nprivate and secure by default:\n\n```typescript\nasync function accessToken() {\n  const auth = new GoogleAuth({\n    scopes: [\"https://www.googleapis.com/auth/cloud-platform\"],\n  });\n  return (await auth.getAccessToken()) || undefined;\n}\n```\n\n### Creating the Docker image\n\nIn order to run `docker build` from Pulumi, we'll need to add Pulumi Docker provider and define\n`Image` resource.\n\nFirst, adding a provider with `bun add '@pulumi/docker'` and now defining an image as follows.\n\n```typescript\nconst webstormImage = new docker.Image(\"webstormImage\", {\n  build: {\n    context: `${__dirname}/base_images/webstorm`,\n    dockerfile: `${__dirname}/base_images/webstorm/Dockerfile`,\n    platform: \"linux/amd64\",\n  },\n  imageName: pulumi.interpolate`${artifactRegistry.registryUri}/webstorm:latest`,\n  registry: {\n    server: artifactRegistry.registryUri,\n    username: \"oauth2accesstoken\",\n    password: await accessToken(),\n  },\n  skipPush: false,\n});\n```\n\nWe're using Dockerfile we created before and Artifact Registry as a destination for our image. And\nalso authenticating our code to access the registry using a short-term OAuth2 access token.\n\n### Preparing the Workstation configuration\n\nWith the image ready, you can add `container.image` property to the `WorkstationConfig` to override\nthe base image. We will also configure the GCE host with a specified machine type and home disk. And\nto finalize the setup we'll configure automatic VM idle and run time — this will allow us to save\nsome costs when the machine is not actively used.\n\n```typescript\nconst wsCustomizedConfig = new gcp.workstations.WorkstationConfig(\n  \"wsCustomizedConfig\",\n  {\n    workstationConfigId: \"customized-config\",\n    workstationClusterId: wsCluster.workstationClusterId,\n    location: region,\n    container: {\n      image: webstormImage.repoDigest,\n    },\n    idleTimeout: \"3600s\",\n    runningTimeout: \"43200s\",\n    host: { gceInstance: { machineType: \"e2-standard-4\" } },\n    persistentDirectories: [\n      {\n        mountPath: \"/home\",\n        gcePd: {\n          diskType: \"pd-standard\",\n          sizeGb: 200,\n          reclaimPolicy: \"DELETE\",\n        },\n      },\n    ],\n  },\n  { provider: gcpProvider, dependsOn: services },\n);\n```\n\n## Summary\n\nWe are all set. Running `pulumi up` and in a bit of time we have our own Code OSS + WebStorm setup\nwith a fully customized base image that your team can reuse easily without having to think twice\nabout which version of the tool they had and if there's anything else they missed.\n\nWe have reviewed three options for customizing Cloud Workstations base images. We prepared a custom\nbase image with Code OSS and WebStorm support and updated our Pulumi setup to automatically build\nDocker image and update our workstations configuration.\n\nIn the next part we will cover the networking and security part of the setup.\n\n## Cleanup\n\nIf you want to delete the resources Pulumi created, just run `pulumi down` and it will take care of\nthe cleanup.\n\n## Useful resources\n\n- [GCP Cloud Workstations How To repository](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/tree/main/customized)\n- [Cloud Workstations home page](https://cloud.google.com/workstations)\n- [Pulumi GCP provider Workstations page](https://www.pulumi.com/registry/packages/gcp/api-docs/workstations/workstation/)\n- [Pulumi Docker provider](https://www.pulumi.com/registry/packages/docker/)\n- [Artifact Registry](https://docs.cloud.google.com/artifact-registry/docs/overview)",
      "date_published": "2025-11-17T00:00:00.000Z",
      "date_modified": "2026-08-18T00:00:00.000Z",
      "tags": [
        "Google Cloud",
        "Cloud Workstations",
        "Pulumi",
        "DevOps",
        "Docker",
        "Developer Experience"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.Drh6jNAg_ZbI91W.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/cloud-workstations-building-reusable-development-environments-in-cloud/",
      "url": "https://serhiichuk.dev/blog/cloud-workstations-building-reusable-development-environments-in-cloud/",
      "title": "Cloud Workstations: building reusable development environments in cloud",
      "summary": "A minimal Google Cloud Workstations setup with Pulumi, TypeScript and Bun: VPC, cluster, config and a first workstation, from project bootstrap to pulumi up.",
      "content_text": "There are multiple ways to solve the dev environment question, whereas having a local development environment may be superior to any other setup, but a cloud-based environment that is close to your workloads has its set of benefits as well.\n\n## Local vs Cloud environment\n\nPerformance and smoothness of your local environment is something no other setup can beat (assuming you have good-enough hardware) but even with top-notch hardware you will face a lot of tedious things to set up, configure and follow to make your environment ready. To name a few — configure and whitelist your IP to talk to the databases or maybe configure VPN. Install a particular version of Python or Node JS your team is working with and don't forget about all the extensions in your favourite IDE to make it a smooth ride. And don't forget that you may need some specific piece of software that only runs under a particular operating system or maybe can't run outside of a secure perimeter.\n\nOn the other hand, a cloud-based and repeatable setup may give you peace of mind with the majority of these. You can have the VMs whitelisted for proper access from the get-go without having to ask anyone for extra assistance. You may install all your favourite or just required toolsets and you can also pre-package extensions of your choice and build them into the setup.\n\n## Google Cloud Workstations customizable setup\n\n[Google Cloud Workstations](https://cloud.google.com/workstations) provide a way to build exactly that setup for your engineers that gives you the ability to create different configurations with the desirable runtime container with all the things you need. Workstations are managed by Cloud Workstations control plane, but the virtual machines themselves reside in your VPC so you can control what and how they can access in your setup. You can e.g. disable public IP for a VM and configure Cloud NAT to ensure smooth access to restricted resources.\n\n## Minimal Google Cloud Workstations setup with Pulumi\n\nIn order to give it a taste and spin it up ASAP, we'll go with a minimal setup while still utilizing infrastructure best practices by using [Pulumi](https://www.pulumi.com/) for our infrastructure as code and TypeScript to have a coherent dev-friendly setup. We will also use [Bun](https://bun.com/) for its ease and speed (you can use any other environment of choice).\n\nCheck out the complete setup [here](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/tree/main/minimal).\n\n## Bootstrapping a project\n\nIn order to bootstrap a new Pulumi project we'll need [Pulumi CLI](https://www.pulumi.com/docs/get-started/download-install/) and [Bun CLI](https://bun.com/docs/installation) and [gcloud CLI](https://docs.cloud.google.com/sdk/docs/downloads-interactive#linux-mac):\n\n```bash\ncurl -fsSL https://get.pulumi.com | bash\ncurl -fsSL https://bun.com/install | bash\ncurl -fsSL https://sdk.cloud.google.com | bash\n```\n\nYou will also need to [create a Pulumi account](https://app.pulumi.com/signup) and login with:\n\n```bash\npulumi login\n```\n\nIf you haven't done this before, you'll need to log in with application-default credentials in gcloud CLI:\n\n```bash\ngcloud auth application-default login\n```\n\nAnd now either create a new GCP project or set the one you're willing to use:\n\n```bash\ngcloud projects create <my-project-id> --name=\"My project Name\"\n# OR\ngcloud config set project <my-project-id>\n```\n\nNow here's how you can bootstrap a project using Pulumi CLI:\n\n```bash\nmkdir gcp-cloud-workstations && cd gcp-cloud-workstations\npulumi new typescript \\\n  --language=typescript \\\n  --runtime-options=packagemanager=bun \\\n  --name=gcp-cloud-workstations \\\n  --description='Minimal setup of the GCP Cloud Workstations with Typescript and Bun' \\\n  --stack=main\n```\n\nYou can also just use plain `pulumi new typescript` and follow the project creation wizard. So in the abovementioned command we've asked Pulumi to create a TypeScript-based project with Bun as a package manager with a defined name, description and [stack name](https://www.pulumi.com/docs/iac/concepts/stacks/).\n\nIt will take care of creating `index.ts`, `tsconfig` and `package.json` as well as `Pulumi.yaml`.\n\nLet's now add the `@pulumi/gcp` provider:\n\n```bash\nbun add -E '@pulumi/gcp'\n```\n\nAnd set the required project ID config for the provider:\n\n```bash\npulumi config set 'gcp:project' <my-project-id>\n```\n\nWe're all set to start writing up our infrastructure as code now.\n\n## Adding IaC to the project\n\nLet's start by defining a dedicated GCP provider instance in our code.\n\n```typescript\nimport * as gcp from \"@pulumi/gcp\";\n\nconst region = \"us-central1\";\nconst gcpProvider = new gcp.Provider(\"gcpProvider\", {\n  project: gcp.config.project,\n  region: region,\n  defaultLabels: { team: \"devops\" },\n});\n```\n\nIt's a recommended best practice that will ensure that all our resources are created with particular defaults and also has a handy feature such as adding labels to all eligible resources.\n\n### Enabling GCP services\n\nIf you're going to add Cloud Workstations into a new GCP project, one will need the required GCP services to be enabled. We can do that from code as well. `new gcp.projects.Service` creates a service resource that manages [enablement of services in GCP](https://cloud.google.com/service-usage/docs/enable-disable).\n\n```typescript\nconst enableServices = (services: string[]) => {\n  return services.map((service) => {\n    return new gcp.projects.Service(\n      service,\n      {\n        service: service,\n        project: gcp.config.project,\n      },\n      { provider: gcpProvider },\n    );\n  });\n};\n\nconst requiredServices = [\n  \"compute.googleapis.com\",\n  \"workstations.googleapis.com\",\n];\n\nconst services = enableServices(requiredServices);\n```\n\nYou can also enable services using the `gcloud` CLI if you don't want to have this managed with IaC:\n\n```bash\ngcloud services enable compute.googleapis.com workstations.googleapis.com\n```\n\n### Setting up Workstations cluster and configuration\n\nNow when the services are enabled, we can configure the workstations and related resources.\n\n```typescript\nexport default async function main() {\n  const wsNetwork = new gcp.compute.Network(\n    \"wsNetwork\",\n    {\n      autoCreateSubnetworks: false,\n    },\n    {\n      provider: gcpProvider,\n      dependsOn: services,\n    },\n  );\n  const wsSubnetwork = new gcp.compute.Subnetwork(\n    \"wsUsCentral1Subnet\",\n    {\n      network: wsNetwork.id,\n      region: region,\n      ipCidrRange: \"10.128.0.0/20\",\n    },\n    {\n      provider: gcpProvider,\n      parent: wsNetwork,\n    },\n  );\n  const wsCluster: gcp.workstations.WorkstationCluster =\n    new gcp.workstations.WorkstationCluster(\n      \"developmentCluster\",\n      {\n        workstationClusterId: \"test-cluster\",\n        network: wsNetwork.id,\n        subnetwork: wsSubnetwork.id,\n        location: region,\n        displayName: \"Test Cluster\",\n        annotations: {\n          description: \"Minimal cluster for testing\",\n        },\n        labels: {\n          purpose: \"test\",\n        },\n      },\n      { provider: gcpProvider, dependsOn: services },\n    );\n  const wsMinimalConfig = new gcp.workstations.WorkstationConfig(\n    \"wsMinimalConfig\",\n    {\n      workstationConfigId: \"minimal-config\",\n      workstationClusterId: wsCluster.workstationClusterId,\n      location: region,\n    },\n    { provider: gcpProvider, dependsOn: services },\n  );\n  return {\n    wsCluster: wsCluster.name,\n    wsConfig: wsMinimalConfig.name,\n  };\n}\n```\n\nThe `export default function main` gives us flexibility in using `await` inside our infrastructure code and also works as an entry point for Pulumi. The returned object will be converted to [stack outputs](https://www.pulumi.com/tutorials/building-with-pulumi/stack-outputs/).\n\nFirst, we define a [GCP VPC network](https://docs.cloud.google.com/vpc/docs/vpc) and subnetwork — this will keep our cluster isolated from the start and that's where the virtual machines are going to be created.\n\nThen we define a `WorkstationCluster` itself — this creates a new control plane of Cloud Workstations that manages instances.\n\nThe `WorkstationConfig` is a template for creating new virtual machines. The very minimal one as we have here just specifies the cluster to work with and the location. It will use Code OSS IDE by default, provision `e2-standard-4` virtual machines and use the project-default Compute Engine service account to run the VMs.\n\nNow all that's left is to call `pulumi up` and see how your first workstations cluster is going to be created.\n\n### Adding workstations\n\nYou can now add workstations with the same IaC setup using the following snippet:\n\n```typescript\n  const workstation = new gcp.workstations.Workstation(\n    \"test-workstation\",\n    {\n      workstationId: \"test-workstation\",\n      workstationConfigId: wsMinimalConfig.workstationConfigId,\n      workstationClusterId: wsCluster.workstationClusterId,\n      location: region,\n    },\n    { provider: gcpProvider, dependsOn: services },\n  );\n```\n\nOr you can as well let your engineers create workstations using [Google Cloud UI](https://console.cloud.google.com/workstations/create).\n\n![Cloud Workstations — Create workstation widget](./create-workstation-widget.png)\n\nOr using `gcloud`:\n\n```bash\ngcloud workstations create test-workstation \\\n  --cluster=test-cluster \\\n  --config=minimal-config \\\n  --region=us-central1\n```\n\n---\n\nHere is what the complete setup script looks like ([also available as a gist](https://gist.github.com/xSAVIKx/78eaeb461417f265fda71771cf8bb872)):\n\n```typescript\nimport * as gcp from \"@pulumi/gcp\";\n\nconst region = \"us-central1\";\nconst gcpProvider = new gcp.Provider(\"gcpProvider\", {\n  project: gcp.config.project,\n  region: region,\n  defaultLabels: { team: \"devops\" },\n});\n\nconst enableServices = (services: string[]) => {\n  return services.map((service) => {\n    return new gcp.projects.Service(\n      service,\n      {\n        service: service,\n        project: gcp.config.project,\n      },\n      { provider: gcpProvider },\n    );\n  });\n};\n\nconst requiredServices = [\n  \"compute.googleapis.com\",\n  \"workstations.googleapis.com\",\n];\n\nconst services = enableServices(requiredServices);\n\nexport default async function main() {\n  const wsNetwork = new gcp.compute.Network(\n    \"wsNetwork\",\n    {\n      autoCreateSubnetworks: false,\n    },\n    {\n      provider: gcpProvider,\n      dependsOn: services,\n    },\n  );\n  const wsSubnetwork = new gcp.compute.Subnetwork(\n    \"wsUsCentral1Subnet\",\n    {\n      network: wsNetwork.id,\n      region: region,\n      ipCidrRange: \"10.128.0.0/20\",\n    },\n    {\n      provider: gcpProvider,\n      parent: wsNetwork,\n    },\n  );\n  const wsCluster: gcp.workstations.WorkstationCluster =\n    new gcp.workstations.WorkstationCluster(\n      \"developmentCluster\",\n      {\n        workstationClusterId: \"test-cluster\",\n        network: wsNetwork.id,\n        subnetwork: wsSubnetwork.id,\n        location: region,\n        displayName: \"Test Cluster\",\n        annotations: {\n          description: \"Minimal cluster for testing\",\n        },\n        labels: {\n          purpose: \"test\",\n        },\n      },\n      { provider: gcpProvider, dependsOn: services },\n    );\n  const wsMinimalConfig = new gcp.workstations.WorkstationConfig(\n    \"wsMinimalConfig\",\n    {\n      workstationConfigId: \"minimal-config\",\n      workstationClusterId: wsCluster.workstationClusterId,\n      location: region,\n    },\n    { provider: gcpProvider, dependsOn: services },\n  );\n  const workstation = new gcp.workstations.Workstation(\n    \"test-workstation\",\n    {\n      workstationId: \"test-workstation\",\n      workstationConfigId: wsMinimalConfig.workstationConfigId,\n      workstationClusterId: wsCluster.workstationClusterId,\n      location: region,\n    },\n    { provider: gcpProvider, dependsOn: services },\n  );\n  return {\n    wsCluster: wsCluster.name,\n    wsConfig: wsMinimalConfig.name,\n    workstation: workstation.host,\n  };\n}\n```\n\n---\n\n### Starting workstations\n\nA workstation is created in a stopped state, so you are only paying for the attached disk and not the CPU/RAM yet. Whenever you're ready you can start it from the UI or with the `gcloud workstations start` command.\n\n:::full\n![Cloud Workstations — Workstations overview](./workstations-overview.png)\n:::\n\nUpon launching a workstation you receive a full-featured development environment powered by [Code OSS](https://github.com/Microsoft/vscode).\n\n:::full\n![Cloud Workstations — Code OSS environment](./code-oss-environment.png)\n:::\n\nWhile being useful by itself, the biggest benefit of having cloud development environments, in my opinion, comes from making them your own, with a pre-configured set of tooling, extensions and access rights that suit you and your team the best.\n\nWe will cover the customization part of the Cloud Workstations setup in the [next article](/blog/cloud-workstations-building-reusable-development-environments-in-cloud-part-2/).\n\n---\n\n### Cleanup\n\nIf you want to delete the resources Pulumi created, just run `pulumi down` and it will take care of the cleanup.\n\n## Useful resources\n\n- [GCP Cloud Workstations How To repository](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/tree/main/minimal)\n- [Cloud Workstations home page](https://cloud.google.com/workstations)\n- [Pulumi GCP provider Workstations page](https://www.pulumi.com/registry/packages/gcp/api-docs/workstations/workstation/)\n- [Bun docs](https://bun.com/docs)",
      "date_published": "2025-10-26T00:00:00.000Z",
      "date_modified": "2026-08-18T00:00:00.000Z",
      "tags": [
        "Google Cloud",
        "Cloud Workstations",
        "Pulumi",
        "Infrastructure as Code",
        "DevOps",
        "TypeScript"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.B1ZmY9nn_3B0ez.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/keeping-your-gists-in-sync-with-the-source-repository/",
      "url": "https://serhiichuk.dev/blog/keeping-your-gists-in-sync-with-the-source-repository/",
      "title": "Keeping your gists in sync with the source repository",
      "summary": "Gists go stale the moment the source file moves on. A GitHub Action that re-uploads them on every release, so the shared copy never drifts.",
      "content_text": "Often one may use GitHub Gists to share a piece of code or configuration which is a very handy way of content distribution. And it's very likely that the code or text you are sharing is coming from a repository.\n\nThe problem that sometimes occurs with such an approach is that repository code evolves and gists become unmaintainable. This is usually not intended when you share a gist directly and is even more problematic when a gist is used e.g. in an article like this one.\n\n## Automating Gist updates with GitHub Actions\n\nTo solve the problem that may not have existed I created a GitHub action that updates gists for you whenever the source file of the repository changes. Welcome [gist-uploader-action](https://github.com/marketplace/actions/gist-uploader).\n\nAll you need is a [GitHub token](https://github.com/settings/tokens) with `gist` scope and a workflow with the Gist ID and the path to the file you're sharing:\n\n```yaml\nname: Update Gist\n\non:\n  release:\n    types:\n      - published\n  workflow_dispatch:\n\njobs:\n  update-gist:\n    runs-on: ubuntu-latest\n    outputs:\n      url: ${{ steps.gist.outputs.url }}\n    steps:\n      - uses: actions/checkout@v5\n\n      - name: Update gist from file\n        id: gist\n        uses: xSAVIKx/gist-uploader-action@v0.1.3       # pin to a released version\n        with:\n          token: ${{ secrets.GIST_TOKEN }}\n          gist_id: eeb24ff793d2d47b4f2f023ba554aee4     # ID of the target gist\n          file_path: .github/workflows/update-gist.yml  # path in your repo to upload\n          # Optional inputs:\n          gist_description: \"This gist is auto-updated using https://github.com/xSAVIKx/gist-uploader-action action\"\n          gist_file_name: \"update-gist.yml\" # defaults to the basename of file_path\n\n      - name: Print gist URL\n        run: |\n          echo \"Gist URL: ${{ steps.gist.outputs.url }}\" >> $GITHUB_STEP_SUMMARY\n```\n\n_An example workflow that updates a Gist using GitHub Actions_\n\nThe workflow above uses the aforementioned [gist-uploader-action](https://github.com/xSAVIKx/gist-uploader-action) to automate the update of the gist with the workflow that automated the update of the gist 😃\n\nI'm currently exploring other possible use cases for this action such as creating gists and keeping them up-to-date with externally-accessible files. Create an issue or reach out if that's something you may want to see in this action.",
      "date_published": "2025-10-24T00:00:00.000Z",
      "date_modified": "2026-08-14T00:00:00.000Z",
      "tags": [
        "DevOps",
        "GitHub Actions"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.6fr7uvz4_1DHxkW.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/google-skills-an-evolution-of-online-learning/",
      "url": "https://serhiichuk.dev/blog/google-skills-an-evolution-of-online-learning/",
      "title": "Google Skills — an evolution of online learning",
      "summary": "Qwiklabs to Skills Boost to Google Skills: what changed across three generations of Google's learning platform, and what the gamified rebuild actually adds.",
      "content_text": "As Google Skills platform is going online I wanted to share a sneak peek at the Google education platform evolution and what's going to be next.\n\nGoogle has [acquired Qwiklabs](https://cloud.google.com/blog/topics/inside-google-cloud/welcome-qwiklabs-google-cloud) back in 2016 when it was one of the best to the time platforms for practicing real-world skills that one can use and apply in work.\n\nI started using the platform back in 2018 when preparing for my first Professional Cloud Architect certification and use it now as well! That's quite a time for an online platform and to be honest I love the changes that were introduced over time.\n\n![Continuous Professional Cloud Architect from 2018 till 2027](./pca-certification-timeline.png)\n\nBy the way, in my opinion GCP certification exams are one of the best there on the market that really test your knowledge and experience.\n\n## Qwiklabs to Google Cloud Skills Boost to Google Skills\n\nSince my initial experience with Qwiklabs the platform has grown and eventually became Google Cloud Skills Boost.\n\nSkills Boost introduced dedicated learning paths and a whole set of new labs and experiences. Moreover Certification Paths were introduced, they allowed you to have a deep dive into a particular cloud specialization with a lot of hands-on activities.\n\nThe Skills Boost catalog has over 1300 labs in every aspect of cloud learning you can think of.\n\n:::wide\n![The Google Cloud Skills Boost catalog](./skills-boost-catalog.png)\n:::\n\nBut what's the next step in Google online learning?\n\n## Google Skills\n\n::youtube[Google Skills Full Anthem Video]{id=BTmn0Je4mh0}\n\nAs an evolution of the platform, Google Skills provides all the materials that were hand-crafted for Qwiklabs and Skills Boost but also adds new gamification features to the learning.\n\nIt encourages friendly competition with new learning Leagues that are based on earned points in the platform. I'm on my way to the Gold league now 😄 I was able to experience the new features a bit earlier being a GDE and part of the Beta platform launch.\n\n![Google Skills learning Leagues](./google-skills-leagues.png)\n\nYou can also unlock new Achievements and hit Learning streaks to promote consistent learning habits and celebrate your learning milestones.\n\nAll these features together make learning a bit more fun and the experience more enjoyable overall.\n\n![Google Skills Achievements](./google-skills-achievements.png)\n\nTo top it up Google incorporates even more learning opportunities into the new platform with nearly 3000 courses and labs in one place — now including content from across Google Cloud, Google DeepMind, Grow with Google and Google for Education.\n\n:::wide\n![Google Skills Home Page](./google-skills-home.png)\n:::\n\n![Google Skills DeepMind courses](./google-skills-deepmind.png)\n\nCheck it out at [skills.google](https://skills.google) today!",
      "date_published": "2025-10-22T00:00:00.000Z",
      "date_modified": "2026-08-14T00:00:00.000Z",
      "tags": [
        "Google Cloud",
        "Learning",
        "Certification"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.BER8WcoK_2bz61b.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/is-the-future-of-autonomous-ai-agents-already-here-jules-api-and-mcp/",
      "url": "https://serhiichuk.dev/blog/is-the-future-of-autonomous-ai-agents-already-here-jules-api-and-mcp/",
      "title": "Is the future of autonomous AI agents already here? Jules API and MCP",
      "summary": "Google shipped a public API for Jules. I put an MCP server on top of it so any AI tool can kick off autonomous Jules coding sessions.",
      "content_text": "There have been a lot of advancements in autonomous AI agents lately and multiple teams are moving their own agents forward at the speed of light.\n\nOne fascinating thing that dropped just [yesterday](https://developers.googleblog.com/en/level-up-your-dev-game-the-jules-api-is-here/) (21 hours ago) is Google's Jules public API.\n\nThe API provides a way to start Jules coding sessions from e.g. `curl` calls and the terminal or by introducing Jules into your own application or wrapper. But an API also means that we can create an MCP server on top of it and start calling the AI agent from other AI tools as well! That's exactly what we're gonna do.\n\n## TLDR\n\nYou can use the Jules MCP server already! It's available here: [https://github.com/CodeAgentBridge/jules-mcp-server](https://github.com/CodeAgentBridge/jules-mcp-server)\n\nAdd it to your MCP server config like this:\n\n```json\n{\n  \"mcpServers\": {\n    \"jules\": {\n      \"command\": \"uv\",\n      \"args\": [\n        \"run\",\n        \"--with\",\n        \"fastmcp\",\n        \"--with\",\n        \"jules-agent-sdk\",\n        \"--with\",\n        \"requests\",\n        \"fastmcp\",\n        \"run\",\n        \"jules_mcp/jules_mcp.py\"\n      ],\n      \"env\": {\n        \"JULES_API_KEY\": \"${JULES_API_KEY}\"\n      }\n    }\n  }\n}\n```\n\nMake sure you have `uv` installed and clone the repo:\n\n```bash\ngit clone https://github.com/CodeAgentBridge/jules-mcp-server jules-mcp-server\ncurl -LsSf https://astral.sh/uv/install.sh | sh\n```\n\n## Bootstrapping an MCP server with Fast MCP\n\nFascinating enough I was already able to find [an unofficial Python SDK for the Jules API](https://github.com/AsyncFuncAI/jules-agent-sdk-python), so the only missing part was the server. Here's where [Fast MCP](https://gofastmcp.com/getting-started/welcome) shines for Python developers. All you need is to add Fast MCP and Jules SDK dependencies:\n\n```bash\nuv add jules-agent-sdk\nuv add fastmcp\n```\n\nAnd now you can start defining your MCP server as follows:\n\n```python\nimport os\n\nfrom fastmcp import FastMCP\nfrom jules_agent_sdk import JulesClient, models\n\nmcp = FastMCP(\"Jules MCP Server\")\n\njules = JulesClient(os.getenv(\"JULES_API_KEY\"))\n\n@mcp.tool(\n    name=\"create_session\",\n    title=\"Create session\",\n    description=\"Create a new Jules session for a given source and prompt.\",\n    tags={\"sessions\"},\n)\ndef create_session(\n        prompt: str,\n        source: str,\n        starting_branch: str | None = None,\n        title: str | None = None,\n        require_plan_approval: bool = False,\n) -> models.Session:\n    \"\"\"Create a new session.\n\n    Args:\n        prompt: The prompt to start the session with.\n        source: The source to use (e.g., 'sources/abc123').\n        starting_branch: Optional starting branch for GitHub repos.\n        title: Optional human-friendly title for the session.\n        require_plan_approval: If True, the plan requires explicit approval before execution.\n    \"\"\"\n    session = jules.sessions.create(\n        prompt=prompt,\n        source=source,\n        starting_branch=starting_branch,\n        title=title,\n        require_plan_approval=require_plan_approval,\n    )\n    return session\n\nif __name__ == '__main__':\n    mcp.run()\n```\n\nThis is the very basic version of the MCP server that already allows you to start new developer sessions with Jules!\n\n## Real-world usage\n\nTo make the server more helpful, you of course need to add all the other exposed APIs as it'll let your AI client be way more helpful, track execution of tasks and much more.\n\nHere are some examples of integrating the server into JetBrains PyCharm IDE and its own AI chat.\n\n![PyCharm AI chat starting a new Jules coding session through the MCP server](./pycharm-ai-chat-create-session.png)\n\n![PyCharm AI chat reporting the Jules session is IN_PROGRESS](./pycharm-ai-chat-session-status.png)\n\nUnfortunately you can't yet do active actions such as publishing PRs or pushing branches from the API (or at least Jules was not able to do that for me):\n\n![PyCharm AI chat forwarding a \"publish branch\" message to Jules](./pycharm-ai-chat-publish-branch.png)\n\n![The Jules web UI, where the branch and the PR were actually published](./jules-ui-publish-pr.png)\n\nIt only worked when you clicked the PR button in the UI. But that's a great start already, don't you think?\n\nHere's the PR by the way: [https://github.com/CodeAgentBridge/jules-mcp-server/pull/1](https://github.com/CodeAgentBridge/jules-mcp-server/pull/1)\n\n## Closing statements\n\nWhile we're not yet there to make it fully autonomous, the tooling support and exposure via APIs of more and more services and systems is definitely a big leap forward. It's been less than 24 hours, so I'm really looking forward to all the new stuff that will emerge from these new capabilities.\n\n## References\n\n- [https://github.com/CodeAgentBridge/jules-mcp-server](https://github.com/CodeAgentBridge/jules-mcp-server)\n- [https://developers.googleblog.com/en/level-up-your-dev-game-the-jules-api-is-here/](https://developers.googleblog.com/en/level-up-your-dev-game-the-jules-api-is-here/)\n- [https://developers.google.com/jules/api](https://developers.google.com/jules/api)\n- [https://github.com/AsyncFuncAI/jules-agent-sdk-python](https://github.com/AsyncFuncAI/jules-agent-sdk-python)",
      "date_published": "2025-10-05T00:00:00.000Z",
      "date_modified": "2026-08-16T00:00:00.000Z",
      "tags": [
        "AI Agents",
        "MCP",
        "Google Cloud",
        "Jules",
        "Python"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.C69k1GdF_Z1nLB5R.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/from-autopilot-to-standard-gke-the-key-to-15x-cheaper-istio/",
      "url": "https://serhiichuk.dev/blog/from-autopilot-to-standard-gke-the-key-to-15x-cheaper-istio/",
      "title": "From Autopilot to Standard GKE: The Key to 15x Cheaper Istio",
      "summary": "Istio proxy costs for a 10-node GKE cluster reduced from $3065 to $185 per month by moving from GKE Autopilot to GKE Standard with Istio Ambient.",
      "content_text": "_TL;DR: Istio proxy costs for a 10-node GKE cluster reduced from \\$3065 to \\$185 per month._\n\n[GKE Autopilot](https://cloud.google.com/kubernetes-engine/docs/concepts/autopilot-overview) is an amazing product that extremely simplifies the development for teams by making it easy to work with Kubernetes workloads without a need to worry about node pools, nodes themselves, their management, etc.\n\nGKE Autopilot is indeed a comprehensive solution where the majority of workloads would be just perfectly fine and those that scale up and down frequently would not only be easier to manage but also [way more cost-effective](https://medium.com/@gallaghersam95/gke-autopilot-cost-efficiency-c7d5b01946dd).\n\nBut as with the majority of solutions it always comes down to your particular use case. I am working on one where GKE Autopilot, even though it is easier to manage combined with [Cloud Service Mesh](https://cloud.google.com/products/service-mesh?hl=en) (managed [Istio](https://istio.io/)), may be a little bit heavy money-wise. So we decided to analyze how much it would save us if we migrate our workloads from GKE Autopilot to GKE Standard while also migrating off Cloud Service Mesh to [Istio Ambient](https://istio.io/latest/docs/ambient/overview/) per-node proxies.\n\n---\n\nLet's talk about workloads and resources. In our case we're running a cluster with a hundred deployments. Every deployment has from one to thirty active pods. Every pod usually runs a single container with the application code.\n\nOn average we're running around **240 pods** around the clock with some deviations when the traffic flows increase. The average pod workload requests 0.5 vCPU and 700Mi of RAM. So we're looking at around **120 vCPU** and **178 GB RAM** of GKE Autopilot resource requests.\n\nNow as mentioned before we're running with Cloud Service Mesh which runs on top of Istio in [sidecar mode](https://istio.io/latest/docs/setup/). It means that for every pod in the mesh we inject an additional Istio proxy container. Istio proxy containers request 100m CPU and 128Mi RAM which is fair but while we're running in GKE Autopilot, it [enforces its own minimal requirements](https://cloud.google.com/kubernetes-engine/docs/concepts/autopilot-resource-requests#min-max-requests) of **250m CPU** and **512Mi RAM** per-container requests.\n\nSo proxy containers for our scenario add an extra **60 vCPU** and **120 GB RAM** which is more than half of the resources the actual workloads consume. But even with lower proxy requirements it would still add up to 24 vCPU and 30 GB RAM which is quite a lot.\n\nTalking money we're looking at the cost of \\$5843 per month for the workloads.\n\n![Calculations for resources consumptions of GKE Autopilot workloads](./autopilot-workload-cost-formula.png)\n\nAnd an extra cost of \\$3065 per month for the proxies (using current GKE Autopilot [pricing](https://cloud.google.com/kubernetes-engine/pricing#autopilot_mode) for the europe-west2 region) around 34.4% of the total cost and more than half of what workloads cost.\n\n![Calculations for the service mesh proxies costs in GKE Autopilot](./autopilot-proxy-cost-formula.png)\n\n---\n\nSo what choices do we have here to reduce the cost while keeping the mesh functionality? The answer to that is [Istio Ambient mode](https://istio.io/latest/docs/ambient/). 🔥 Istio has just [announced](https://istio.io/latest/blog/2024/ambient-reaches-ga/) Ambient going GA from Istio version 1.24.\n\nIstio in Ambient mode moves the proxies from pods to nodes. Ambient requires [Istio CNI](https://istio.io/latest/docs/setup/additional-setup/cni/) and [ztunnel](https://github.com/istio/ztunnel) daemon sets to run on the nodes, so every new node will add at least two new containers. The ztunnel container requires 200m CPU and 500Mi RAM and the CNI container requires 100m CPU and 100Mi RAM.\n\nMoving from per-pod sidecar proxies to a per-node Ambient setup makes it possible to reduce the costs of our case from around \\$3065 per month to around \\$185 (when running on 10 nodes) for CNI and ztunnel. Depending on the needs you may add a couple of [waypoint proxies](https://istio.io/latest/docs/ambient/usage/waypoint/) which will add an extra \\$20–\\$30 to the total.\n\n---\n\nFor those using GKE Autopilot and Istio, the opportunity to cut costs could make GKE Standard an appealing option worth considering. While both GKE Autopilot and Standard provide robust and reliable solutions, the decision comes down to your project requirements and long-term goals.",
      "date_published": "2024-11-09T00:00:00.000Z",
      "date_modified": "2026-08-16T00:00:00.000Z",
      "tags": [
        "Google Cloud",
        "GKE",
        "Kubernetes",
        "Istio",
        "Service Mesh",
        "FinOps"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.CEwBk6EF_1YmxQt.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/going-beyond-http-timeouts-in-gcp-workflows-practice/",
      "url": "https://serhiichuk.dev/blog/going-beyond-http-timeouts-in-gcp-workflows-practice/",
      "title": "Going beyond standard HTTP timeouts in GCP Workflows — the practice",
      "summary": "Deploying a fully functional App Engine Tasks Runner and the GCP infrastructure around it, so Workflows can drive HTTP calls that outlive the standard timeouts.",
      "content_text": "As [previously mentioned](/blog/going-beyond-http-timeouts-in-gcp-workflows-theory/), we will deploy a fully functional AppEngine Tasks Runner service and all the required infrastructure components.\n\nThis article aims to provide you with a simple way of verifying the mentioned approach works and give you guidelines on how to include such a solution in your project.\n\nYou can jump into the [appengine-tasks-runner](https://github.com/xSAVIKx/appengine-tasks-runner) repository and try it out yourself or go through the code or we can do this together throughout this article.\n\nThe application itself is super simplistic and exposes a single FastAPI-based endpoint that accepts structured HTTP request information with the following structure:\n\n```python\nclass HttpServiceRequest(pydantic.BaseModel):\n    \"\"\"A request to call a service.\"\"\"\n\n    url: pydantic.AnyHttpUrl = pydantic.Field(\n        title=\"URL\", description=\"The URL of a service to be called.\"\n    )\n    \"\"\"The URL of a service to be called.\"\"\"\n\n    body: pydantic.BaseModel | dict[str, Any] | str | bytes = pydantic.Field(\n        default_factory=dict, title=\"Body\", description=\"The HTTP request body payload.\"\n    )\n    \"\"\"The request body payload.\"\"\"\n\n    content_type: str = pydantic.Field(\n        default=\"application/json\",\n        title=\"Content Type\",\n        description=\"The HTTP request body content type. Defaults to JSON.\",\n    )\n    \"\"\"The HTTP request body content type. Defaults to JSON.\"\"\"\n\n    method: str = pydantic.Field(\n        default=\"POST\", title=\"Method\", description=\"The HTTP request method.\"\n    )\n    \"\"\"The HTTP request content type.\"\"\"\n\n    headers: dict[str, str] = pydantic.Field(\n        default_factory=dict, title=\"Headers\", description=\"The HTTP request headers.\"\n    )\n    \"\"\"The HTTP request headers.\"\"\"\n\n    timeout: float = pydantic.Field(\n        default=43200, title=\"Timeout\", description=\"The request timeout in seconds.\"\n    )\n    \"\"\"The request timeout in seconds.\"\"\"\n```\n\nAs you can see you’re gonna be able to provide all the required HTTP call configurations when requesting the Tasks Runner to do a request on your behalf.\n\nThe response is pretty simple as well:\n\n```python\nclass HttpServiceResponse(pydantic.BaseModel):\n    \"\"\"A request to call a service.\"\"\"\n\n    body: pydantic.BaseModel | dict[str, Any] | str | bytes | None = pydantic.Field(\n        default_factory=dict, title=\"Body\", description=\"The HTTP response body payload.\"\n    )\n    \"\"\"The response body payload.\"\"\"\n\n    headers: dict[str, str] = pydantic.Field(\n        default_factory=dict, title=\"Headers\", description=\"The HTTP request headers.\"\n    )\n    \"\"\"The HTTP request headers.\"\"\"\n\n    status_code: int = pydantic.Field(\n        title=\"HTTP Status\", description=\"The response HTTP status code.\"\n    )\n    \"\"\"The response HTTP status code.\"\"\"\n```\n\nThe main part of the code is `HttpServiceCaller` and its source code is available [here](https://github.com/xSAVIKx/appengine-tasks-runner/blob/master/appengine_tasks_runner/http_service_caller.py). Upon instantiation, it fetches a secret with a service account and then uses that service account to perform authenticated calls to your services.\n\nAnd `main.py` provides a simple POST request handler that consumes `HttpServiceRequest` objects then passes them to `HttpServiceCaller` and returns back the results as `HttpServiceResponse`.\n\n```python\n@app.post(\"/\")\nasync def handle_task(http_service_request: HttpServiceRequest) -> HttpServiceResponse:\n    \"\"\"Handles AppEngine HTTP Cloud Task requests.\n\n    Sends the request content to the specified by the request URL service and returns\n    back the response.\n    \"\"\"\n    response: HttpServiceResponse = service_caller.call_service(request=http_service_request)\n    return response\n```\n\nSo that’s basically it. The implementation is pretty straightforward and can be improved in multiple different ways, but the magic happens when you combine this simplicity with the unique AppEngine capability of running long-term requests. So let’s proceed and set up a new Tasks Runner service in your own GCP project.\n\nFirst, we will create a new GCP project for demo purposes and you can do that at [https://console.cloud.google.com/projectcreate](https://console.cloud.google.com/projectcreate).\n\n![Creating new GCP project](./create-gcp-project.png)\n\n***Note: Please do not share your project ID as it may pose a security risk as the project ID is widely used in GCP to perform various tasks. (The one on the screenshot is a fake one, no worries)***\n\nIn the new project, we’re jumping into [Cloud Shell Editor](https://console.cloud.google.com/cloudshelleditor) where we clone the `appengine-tasks-runner`. If you are not familiar with Cloud Shell it has a pretty descriptive [documentation](https://cloud.google.com/shell/docs) and a [Cloud Skills Boost lab](https://www.cloudskillsboost.google/focuses/563?parent=catalog) that may help you out.\n\n```bash\ngh repo clone xSAVIKx/appengine-tasks-runner\ncd appengine-tasks-runner\n```\n\n![Cloning the repo in Cloud Shell](./cloud-shell-clone.png)\n\nWith the repository cloned, we’re first going to set up the environment required for the Tasks Runner service itself and then proceed with the deployment of the demo workflow and demo job service.\n\nThe [`setup-env.sh`](https://github.com/xSAVIKx/appengine-tasks-runner/blob/master/setup-env.sh) script is parameterized with `GOOGLE_CLOUD_PROJECT`, `APPENGINE_GCP_REGION` and `TASKS_GCP_REGION` environment variables. The `APPENGINE_GCP_REGION` defaults to `us-central` and `TASKS_GCP_REGION` to `us-central1` — those are the regions where the AppEngine application and Cloud Tasks queue are going to be created.\n\nYou can just run the `setup-env.sh` script and jump over to the service deployment.\n\nBut if you’re interested, here’s what is going on in the script itself.\n\nSo first we propagate the envs:\n\n```bash\nGCP_PROJECT=\"${GOOGLE_CLOUD_PROJECT}\"\nAPPENGINE_GCP_REGION=\"${APPENGINE_GCP_REGION:-us-central}\"\nTASKS_GCP_REGION=\"${TASKS_GCP_REGION:-us-central1}\"\n```\n\nThen we enable all required GCP services:\n\n```bash\ngcloud services enable serviceusage.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable servicemanagement.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable secretmanager.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable cloudapis.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable cloudtasks.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable storage-component.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable monitoring.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable cloudbuild.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable logging.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable appengine.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable iamcredentials.googleapis.com --project=\"${GCP_PROJECT}\"\ngcloud services enable iam.googleapis.com --project=\"${GCP_PROJECT}\"\n```\n\nNow we can create the App Engine application:\n\n```bash\ngcloud app create \\\n  --region=\"${APPENGINE_GCP_REGION}\" \\\n  --project=\"${GCP_PROJECT}\"\n```\n\nIt will take some time and we can proceed to the creation of all the required service accounts and permissions.\n\n```bash\nSERVICE_CALLER_SA=\"service-caller\"\n\ngcloud iam service-accounts create \"${SERVICE_CALLER_SA}\" \\\n  --display-name=\"Service Caller\" \\\n  --description=\"Performs authorized service and API HTTP calls.\" \\\n  --project=\"${GCP_PROJECT}\"\n\nSERVICE_CALLER_SA_EMAIL=\"${SERVICE_CALLER_SA}@${GCP_PROJECT}.iam.gserviceaccount.com\"\n\ngcloud projects add-iam-policy-binding ${GCP_PROJECT} \\\n  --member=\"serviceAccount:${SERVICE_CALLER_SA_EMAIL}\" \\\n  --role=\"roles/cloudfunctions.invoker\" \\\n  --condition=None\ngcloud projects add-iam-policy-binding ${GCP_PROJECT} \\\n  --member=\"serviceAccount:${SERVICE_CALLER_SA_EMAIL}\" \\\n  --role=\"roles/run.invoker\" \\\n  --condition=None\n```\n\nThe Tasks Runner service is going to use the Service Caller secret key to perform the actual calls to the services. You may want to add e.g. `Workflows Invoker` role to the service account if you decide to send callbacks from the Tasks Runner.\n\nSo now we need to export the service account key and create a secret out of it. We can do that all from the CLI as well:\n\n```bash\n# Create a service account JSON key\ngcloud iam service-accounts keys create \"service-caller.key.json\" \\\n  --iam-account=\"${SERVICE_CALLER_SA_EMAIL}\" \\\n  --project=\"${GCP_PROJECT}\"\n\n# Store the key in a Secret Manager secret\ngcloud secrets create \"service-caller-sa-key\" \\\n  --data-file=\"service-caller.key.json\" \\\n  --labels=\"service=appengine-tasks-runner\" \\\n  --project=\"${GCP_PROJECT}\"\n```\n\nThe final step is to create a Cloud Tasks push queue that will be used to propagate tasks to the Tasks Runner. Here’s how to do that:\n\n```bash\ngcloud tasks queues create \"scheduled-tasks\" \\\n  --location=\"${TASKS_GCP_REGION}\" \\\n  --max-attempts=3 \\\n  --max-backoff=\"10s\" \\\n  --max-dispatches-per-second=1 \\\n  --max-concurrent-dispatches=500 \\\n  --routing-override=\"service:tasks-runner\" \\\n  --project=\"${GCP_PROJECT}\"\n```\n\nThe tasks queue configuration is opinionated and you may want to tweak it to match your own needs and resources. For example, you may want to disable retries completely on the Cloud Tasks side and rely on your internal retries logic or you may want to increase the max concurrent tasks per second to improve performance.\n\nAt this moment we have all the required pieces in place and the only thing left is to deploy the App Engine service itself. While the deployment step will probably be used more than once there is a separate [`deploy.sh`](https://github.com/xSAVIKx/appengine-tasks-runner/blob/master/deploy.sh) script to do that:\n\n```bash\nGCP_PROJECT=\"${GOOGLE_CLOUD_PROJECT}\"\n\npoetry export --no-interaction --without-hashes --format requirements.txt --output requirements.txt\n\necho \"gunicorn\" >> requirements.txt\n\ngcloud app deploy app.yaml --project=\"${GCP_PROJECT}\"\n```\n\nYou may need to install `poetry` in order to export the defined dependencies but that’s as easy as running:\n\n```bash\ncurl -sSL https://install.python-poetry.org | POETRY_VERSION=1.8.2 python3 -\n```\n\nWhen successfully deployed you should see the following response when opening the app URL in the browser:\n\n```json\n{\"detail\":\"Method Not Allowed\"}\n```\n\nThat’s OK. We’re not expecting anyone to use GET HTTP requests.\n\nNow we have it the App Engine Tasks Runner service is up and ready and can forward your requests to the services.\n\nSo if your goal was to deploy the service and you’re ready to plug your own services and workflows you’re now good to go. For those who are interested in the e2e example, we have to set up and configure a couple more things. Stay tuned for the testing part of the setup 😉\n\nThis service is still in active use in a serverless data processing platform at [Travelshift](https://travelshift.com/) where we are building next-gen travel experience solutions. You can check it out at [Guide to Europe](https://guidetoeurope.com/) and [Guide to Iceland](https://guidetoiceland.is/).",
      "date_published": "2024-04-21T00:00:00.000Z",
      "date_modified": "2026-08-14T00:00:00.000Z",
      "tags": [
        "Google Cloud",
        "Cloud Workflows",
        "DevOps"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.Dwwr0l5g_1wL6wT.jpeg"
    },
    {
      "id": "https://serhiichuk.dev/blog/going-beyond-http-timeouts-in-gcp-workflows-theory/",
      "url": "https://serhiichuk.dev/blog/going-beyond-http-timeouts-in-gcp-workflows-theory/",
      "title": "Going beyond standard HTTP timeouts in GCP Workflows — the theory",
      "summary": "GCP Workflows' HTTP connector times out at 30 minutes. Why Pub/Sub and Cloud Run don't help, and how App Engine targets on Cloud Tasks get you 24 hours.",
      "content_text": "So you've adopted serverless and started using GCP Workflows for the orchestration. And everything\nis great, but suddenly you're facing an issue with the timing out of one of your service calls.\n\n```yaml\nmain:\n    steps:\n    - getCurrentTime:\n        call: http.get\n        args:\n            url: https://us-central1-workflowsample.cloudfunctions.net/datetime\n        result: currentDateTime\n    - returnOutput:\n        return: ${currentDateTime.body.dayOfTheWeek}\n```\n\nYou're going and checking that your\n[Cloud Function](https://cloud.google.com/functions/docs/configuring/timeout),\n[App Engine](https://cloud.google.com/appengine/docs/standard/how-instances-are-managed#timeout), or\n[Cloud Run](https://cloud.google.com/run/docs/configuring/request-timeout) timeout limit is set to\nthe maximum already (probably 60 minutes) and starting to dig up. Eventually, you understand that\nthe Workflows HTTP connector has only\n[30 minutes timeout](https://cloud.google.com/workflows/docs/reference/stdlib/http/get) for HTTP\nservice calls, so what can you do now?\n\nFirst of all, maybe you need to reconsider if you're doing everything right, and serverless with its\ntighter limits is a good fit for your task. But if that's the case, please welcome under the hood.\n\n## Some analysis\n\nSo synchronous HTTP calls in GCP Workflows have an up to 30 minutes execution timeout limit, so\nlet's maybe try async solutions.\n\nThere are [callbacks](https://cloud.google.com/workflows/docs/creating-callback-endpoints)\navailable, but what if you need to call a serverless GCP service and get a notification back? If you\njust send a long-lived HTTP request to Cloud Functions or Cloud Run they are going to shut down your\nrequest processing as soon as the response is sent back (yes, you may enable\n[\"CPU always allocated\"](https://cloud.google.com/run/docs/configuring/cpu-allocation) but now\nyou're going to pay for this CPU way longer than usually actually needed and we don't want that).\n\nSo we need a solution to keep an HTTP request open and send back a callback to the Workflows to\ncontinue the execution.\n\n![Cloud Run, Cloud Functions, App Engine, Workflows, Pub/Sub and Cloud Tasks icons](./gcp-service-icons.png)\n\nSome prominent services from GCP that pop up in mind and are suited for async executions are Pub/Sub\nand Cloud Tasks. But you already know that Pub/Sub has only\n[10 minutes timeout](https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/modifyAckDeadline)\nfor HTTP triggers, and using a pull subscription is not an option either because Cloud Functions and\nCloud Run require an ongoing HTTP request.\n\nSo what about Cloud Tasks? Well, standard HTTP targets also have up to\n[30 minutes timeout](https://cloud.google.com/tasks/docs/dual-overview#http), but… App Engine targets\nhave up to [**24 hours of a timeout**](https://cloud.google.com/tasks/docs/dual-overview#appe) to\nApp Engine services with basic scaling! And that's our loophole.\n\n![GCP services timeouts](./gcp-services-timeouts.png)\n\n_GCP services HTTP calls timeouts_\n\n## The solution\n\nSo here's what we're gonna do to go beyond the usual 10 or 30 minutes timeout.\n\n![Long running HTTP requests with GCP Workflows diagram](./long-running-http-requests-diagram.png)\n\n_Long-running GCP Workflows HTTP requests setup_\n\nSo whenever you want to have a long-running HTTP request with the Workflows you'd need to:\n\n1. Create a callback URL to notify the Workflows back.\n2. Create an async Cloud Task with the Task Runner App Engine service target and your service call\n   details as the payload.\n3. Unwrap the Cloud Task payload and do a synchronous HTTP call from the Task Runner service to the\n   destination service.\n4. Send back a callback with the service call results (if any).\n5. Continue the workflow execution.\n\n## The updated example\n\nSo how may the example GCP Workflow look now as we have the solution outlined?\n\n```yaml\nmain:\n    steps:\n    - createCallback:\n        call: events.create_callback_endpoint\n        args:\n          http_callback_method: \"POST\"\n        result: callbackDetails\n    - createHttpCallPayload:\n        assign:\n          - httpCallPayload: {}\n          - httpCallPayload.workflows_callback: ${callbackDetails}\n    - createHttpCallTask:\n        assign:\n          - httpCallTask: {}\n          - httpCallTask.method: \"GET\"\n          - httpCallTask.body: ${httpCallPayload}\n          - httpCallTask.content_type: \"application/json\"\n          - httpCallTask.url: \"https://us-central1-workflowsample.cloudfunctions.net/datetime\"\n          - httpCallTask.timeout: 2700\n          - httpCallTaskJson: ${json.encode(httpCallTask)}\n          - httpCallTaskEncodedJson: ${base64.encode(httpCallTaskJson)}\n    - getProjectID:\n        assign:\n          - projectId: ${sys.get_env(\"GOOGLE_CLOUD_PROJECT_ID\")}\n    - scheduleGetCurrentTimeCall:\n        call: googleapis.cloudtasks.v2.projects.locations.queues.tasks.create\n        args:\n          parent: ${\"projects/\" + projectId + \"/locations/us-central1/queues/scheduled-tasks\"}\n          body:\n            task:\n              appEngineHttpRequest:\n                httpMethod: \"POST\"\n                relativeUri: \"/\"\n                headers:\n                  Content-Type: \"application/json\"\n                body: ${httpCallTaskEncodedJson}\n            responseView: \"BASIC\"\n        result: scheduledTask\n    - awaitCallback:\n        call: events.await_callback\n        args:\n          callback: ${callbackDetails}\n          timeout: 3000\n        result: callbackResult\n    - unwrapCallbackResult:\n        assign:\n          - currentDateTime: ${callbackResult.http_request}\n    - returnOutput:\n        return: ${currentDateTime.body.dayOfTheWeek}\n```\n\nThat's basically it. The only thing left is to implement the Task Runner service, set up all the\ninfrastructure, and let your workflow calls run for much longer now.\n\nAnd I will cover the\n[Task Runner implementation](/blog/going-beyond-http-timeouts-in-gcp-workflows-practice/) along with\nan example of the infrastructure setup and the workflow in the next article.\n\nI developed this approach while building a serverless data processing platform at\n[Travelshift](https://travelshift.com/) where we are building next-gen travel experience solutions.\nYou can check it out at [Guide to Europe](https://guidetoeurope.com/) and\n[Guide to Iceland](https://guidetoiceland.is/).",
      "date_published": "2023-01-10T00:00:00.000Z",
      "date_modified": "2026-08-14T00:00:00.000Z",
      "tags": [
        "Google Cloud",
        "Cloud Workflows",
        "DevOps"
      ],
      "image": "https://serhiichuk.dev/_astro/cover.CQ4g5WDZ_OipuK.jpeg"
    }
  ]
}