<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://tales.fromprod.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://tales.fromprod.com/" rel="alternate" type="text/html" /><updated>2026-06-19T22:43:12+00:00</updated><id>https://tales.fromprod.com/feed.xml</id><title type="html">Tales from Prod</title><subtitle>Welcome to Richard Finlay Tweed&apos;s thoughts on Cloud Native Software, Kubernetes, Production and whatever else he&apos;s been tinkering with.</subtitle><entry><title type="html">Google workspace threatening to block firefox access</title><link href="https://tales.fromprod.com/2026/169/google-workspace-threatening-to-block-firefox.html" rel="alternate" type="text/html" title="Google workspace threatening to block firefox access" /><published>2026-06-18T14:00:00+00:00</published><updated>2026-06-18T14:00:00+00:00</updated><id>https://tales.fromprod.com/2026/169/google-workspace-threatening-to-block-firefox</id><content type="html" xml:base="https://tales.fromprod.com/2026/169/google-workspace-threatening-to-block-firefox.html"><![CDATA[<h1 id="google-workspace-threatening-to-block-firefox">Google workspace threatening to block firefox</h1>

<p>At the time of writing (2026-06-18), Google Workspace appears to be starting to warn users from Firefox that they must use Chrome. This was for a <code class="language-plaintext highlighter-rouge">Google Workspace Business Plus</code> account and workspace, from an up to date browser and OS.</p>

<p>At this time, Firefox access still seems to work but I’ve no idea for how long.</p>

<p>| 📝 Update as of 15:31Z 2026-06-18 | Google support called and claim this will only happen for admins trying to access https://admin.google.com and that it isn’t blocking, it’s just a recommendation. They said they will not be documenting this publicly |
| ——————————— | :———————————————————————————————————————————————————————————————————————- |</p>

<h2 id="specific-warning">Specific warning</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
Icon indicating that the user may soon lose their access to their account.
Secure your device for safe app access
To help keep your data secure, make sure that your device meets your organisation's security requirements
Next steps

    Download Chrome Browser and sign in with your work account

</code></pre></div></div>

<p>This was from a webpage with url <code class="language-plaintext highlighter-rouge">https://access.workspace.google.com/remediate?urlparams=REDACTED</code></p>

<p>Screenshot below</p>

<p><img src="/static/2026-06-18-google-workspace-threatening-to-block-firefox/warning-screen.png" alt="A google workspace page with the following text Icon indicating that the user may soon lose their access to their account and that they must install Chrome" width="610" /></p>

<h2 id="response-from-google-support">Response from Google support</h2>

<p>Absolutely nothing useful, repeatedly transferred around and took ages.</p>

<p>«««&lt; HEAD
«««&lt; Updated upstream
=======</p>

<h3 id="emailed-update-from-their-support-after-they-called-me">Emailed update from their support after they called me</h3>

<h1 id="im-publishing-this-in-full-none-of-this-actually-addresses-the-issue-or-answers-anything-i-asked-on-the-call">I’m publishing this in full, none of this actually addresses the issue or answers anything I asked on the call</h1>

<h3 id="emailed-update-from-their-support">Emailed update from their support</h3>

<p>I’m publishing this in full, none of this actually addresses the issue</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[redacted personal information about myself and the support staff]
I appreciate you accepting my call earlier.

To ensure your users have the best, most secure, and feature-rich experience with Google Workspace services, it's crucial to use up-to-date, compatible web browsers. Using supported browsers provides access to the latest features and offers improved security and performance.

Here are the browsers compatible with Google Workspace:

    Google Chrome: We recommend and fully support the latest version of Google Chrome. Chrome typically updates automatically, ensuring access to all Google Workspace features and functionality.

    Mozilla Firefox: Google Workspace works well with Firefox. We support the current and the previous major version. Please note that Firefox does not currently support:
        Offline access to Gmail, Google Calendar, Google Docs, Sheets, and Slides.
        Client-side encryption in Google Meet.

    Apple Safari: Google Workspace also works well with Safari. We support the current and the previous major version. Safari does not currently support:
        Offline access to Gmail, Calendar, Docs, Sheets, and Slides.
        Desktop notifications in Gmail.

    Microsoft Edge: Google Workspace works well with Microsoft Edge. We support the current and the previous major version.

Key Recommendations:

    Keep Browsers Updated: Always encourage users to run the latest versions of these supported browsers. For Firefox, Safari, and Edge, when a new browser version is released, we begin supporting that version and stop supporting the third most recent version.
    Enable Cookies and JavaScript: To use Google Workspace effectively, ensure that both cookies and JavaScript are enabled in the browser settings.
    Unsupported Browsers: While some functionality might work on older or unsupported browsers, we cannot guarantee full feature availability or performance. Users may encounter issues or find some applications do not open correctly.
    Mobile Access: For the best experience on mobile devices (Android, iPhone, and iPad), please use the dedicated Google Workspace mobile applications, which are built specifically for these platforms.

By following these guidelines, your organization can maximize the benefits and security offered by Google Workspace.

For future reference, please check and review these articles:
Supported browsers for Google Workspace | Support &amp; troubleshooting | Google Workspace Help
Service-specific Google Workspace requirements | Support &amp; troubleshooting | Google Workspace Help

Should you have any further questions, we'd be happy to provide assistance. This case will be closed in the next 3 business days, you can always reply to this message within the next 30 days and the case will reopen.

Thank you for choosing Google Workspace, and I hope you have a wonderful day!

Kind regards,
[redacted]
</code></pre></div></div>

<h2 id="why-do-i-care">Why do I care?</h2>

<p>My team need to make sure that their software works in multiple browsers, and I personally prefer using firefox and don’t want to be forced to use Chrome for no discernable benefit.</p>

<h2 id="okay-but-didnt-your-admin-configure-enterprise_feature">Okay, but didn’t your admin configure $enterprise_feature</h2>

<p>Sadly not, I’m the admin and can confirm the following</p>

<ul>
  <li>We haven’t configured, and don’t use IAP (Identity Aware Proxy) - I’ve used this before and yes that is Chrome only due to how it does device verification</li>
  <li>This isn’t because of “Context Aware Access” this is an enterprise only feature, and we’re on <code class="language-plaintext highlighter-rouge">Google Workspace Business Plus</code></li>
</ul>]]></content><author><name></name></author><category term="Google" /><category term="Firefox" /><summary type="html"><![CDATA[Google workspace threatening to block firefox]]></summary></entry><entry><title type="html">Snyk issues in linear</title><link href="https://tales.fromprod.com/2026/091/snyk-linear-sync.html" rel="alternate" type="text/html" title="Snyk issues in linear" /><published>2026-04-01T11:00:00+00:00</published><updated>2026-04-01T11:00:00+00:00</updated><id>https://tales.fromprod.com/2026/091/snyk-linear-sync</id><content type="html" xml:base="https://tales.fromprod.com/2026/091/snyk-linear-sync.html"><![CDATA[<h1 id="snyk-issues-in-linear">Snyk issues in linear</h1>

<p>At the time of writing (2025-04-01) Snyk doesn’t support syncing found issues into <a href="https://linear.app/">Linear</a>, , only <a href="https://docs.snyk.io/integrations/jira-and-slack-integrations/jira-integration">Jira</a> so I decided to make a cli to do this automatically.</p>

<h2 id="requirements">Requirements</h2>

<ul>
  <li>Uses the snyk priorities, and maps them on to due dates based on our compliance SLAs</li>
  <li>auto-close issues when Snyk detects the issue is fixed</li>
  <li>unsubscribes the sync user, so they don’t get thousands of notifications</li>
  <li>Caching where possible, to reduce the odds of rate limiting</li>
  <li>simple to install/run (just <code class="language-plaintext highlighter-rouge">go run</code> from github)</li>
  <li>headless-first, aka you can run it to completion in a cronjob somewhere without needing manual intervention</li>
  <li>Label issues with the snyk tool, and an overarching label (all configurable) to enable clearer dashboards</li>
</ul>

<h3 id="show-me">Show me</h3>

<p>All of this, and more is implemented in <a href="https://github.com/RichardoC/snyk-linear-sync">https://github.com/RichardoC/snyk-linear-sync</a> so give it a go</p>]]></content><author><name></name></author><category term="APIs" /><category term="Security" /><summary type="html"><![CDATA[Snyk issues in linear]]></summary></entry><entry><title type="html">Minimal cloudflared permissions, for a ngrok alternative</title><link href="https://tales.fromprod.com/2026/091/cloudflared-permissions.html" rel="alternate" type="text/html" title="Minimal cloudflared permissions, for a ngrok alternative" /><published>2026-04-01T10:00:00+00:00</published><updated>2026-04-01T10:00:00+00:00</updated><id>https://tales.fromprod.com/2026/091/cloudflared-permissions</id><content type="html" xml:base="https://tales.fromprod.com/2026/091/cloudflared-permissions.html"><![CDATA[<h1 id="minimal-cloudflared-permissions-for-a-ngrok-alternative">Minimal cloudflared permissions, for a ngrok alternative</h1>

<p>Cloudflare have a product (sometimes) called <a href="https://github.com/cloudflare/cloudflared">cloudflared</a> which can be used as an ngrok alternative. I wanted to enable developers to use this, but give them the minimal permissions and only allow it to be used with a specific domain.</p>

<p>After reading hundreds of pages of documentation, and spending hours with their support - finally I found the minimum set required, which can be added to a Cloudflare Member Group’s permission policy.</p>

<p>Sorry about the lack of details, but cloudflare weren’t very specific on why each bit is required</p>

<h2 id="account-level-permissions">Account level permissions</h2>

<ul>
  <li>Cloudflare One Connector Read and Monitor: cloudflared; Required so the cli can see if the tunnel is working</li>
  <li>Cloudflare Gateway; Required to actually configure the tunnel</li>
  <li>Cloudflare Zero Trust; Required to actually configure the tunnel</li>
  <li>Load Balancer; Required to actually configure the tunnel</li>
  <li>Cloudflare Access; Required to actually configure the tunnel</li>
</ul>

<h2 id="zone-level-permissions-scope-to-the-relevant-domain">Zone level permissions (scope to the relevant domain)</h2>

<ul>
  <li>Zone Versioning Read; Required so the cli can see current DNS records</li>
  <li>Domain DNS; Required so the cli can create and update records, to allow traffic to actually get to the tunnel</li>
</ul>]]></content><author><name></name></author><category term="APIs" /><category term="Cloudflare" /><category term="Security" /><summary type="html"><![CDATA[Minimal cloudflared permissions, for a ngrok alternative]]></summary></entry><entry><title type="html">Charge a Mi Band 4 by hand</title><link href="https://tales.fromprod.com/2025/236/charge-mi-band-4-by-hand.html" rel="alternate" type="text/html" title="Charge a Mi Band 4 by hand" /><published>2025-08-24T13:00:00+00:00</published><updated>2025-08-24T13:00:00+00:00</updated><id>https://tales.fromprod.com/2025/236/charge-mi-band-4-by-hand</id><content type="html" xml:base="https://tales.fromprod.com/2025/236/charge-mi-band-4-by-hand.html"><![CDATA[<h1 id="charge-a-mi-band-4-by-hand">Charge a Mi Band 4 by hand</h1>

<p>You’re on holiday, and forgot to charge your smartwatch before you left. You also forgot your charger. Now what?</p>

<p>You’ve tried to source a spare charger but the watch is so old, that you can only get the replacement from China and that won’t arrive until you leave.</p>

<p>You’ve two options</p>

<ul>
  <li>Do without, and wait until you return home to charge it</li>
  <li>Jerry-rig it</li>
</ul>

<h2 id="disclaimer">Disclaimer</h2>

<p>I did this for fun, and accepted I might break my watch. I offer no guarantees that this will work - and every expectation it might break your watch.</p>

<p>On your head be it! I accept no responsibility for anything that happens from reading/following this guide!</p>

<h2 id="jerry-rigging-it">Jerry-rigging it</h2>

<p>The charger just passes 5V to the watch. There doesn’t appear to be any active circuitry in it. Given this, if we know the polarity we can just directly connect 5V</p>

<h3 id="polarity">Polarity</h3>

<p>Below is some ascii art of the <em>bottom</em> of the watch
From my testing, the right contact (in this orientation) is where +5V should go, and the left contact (in this orientation) is where the 0V should go</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    _______
    |     |
    |  _  |
    | | | |
    | |_| |
    |     |
0V  | o o | +5V
    |_____|
</code></pre></div></div>

<h3 id="what-should-i-use-for-the-charger-ports">What should I use for the charger ports?</h3>

<p>Cut the end of an old USB cable, and use the positive/neutral wires… Or in my case use an old PSP charger I found, and put wires in to it to get the 5V/0 since it’s labeled nicely.</p>

<h3 id="how-do-i-hold-the-wires-in-place">How do I hold the wires in place?</h3>

<p>Manually, I’m sure there’s a better way to do it. I didn’t find one with what I had available.</p>]]></content><author><name></name></author><category term="Hardware" /><category term="Nonsense" /><summary type="html"><![CDATA[Charge a Mi Band 4 by hand]]></summary></entry><entry><title type="html">Get Snyk Code to accept PRs</title><link href="https://tales.fromprod.com/2025/226/get-snyk-to-accept-PRs.html" rel="alternate" type="text/html" title="Get Snyk Code to accept PRs" /><published>2025-08-14T12:00:00+00:00</published><updated>2025-08-14T12:00:00+00:00</updated><id>https://tales.fromprod.com/2025/226/get-snyk-to-accept-PRs</id><content type="html" xml:base="https://tales.fromprod.com/2025/226/get-snyk-to-accept-PRs.html"><![CDATA[<h1 id="get-snyk-code-to-accept-prs">Get Snyk Code to accept PRs</h1>

<p>Sometimes you need to merge a PR that uses an HTTP server for a test, and Snyk code is configured to block you. Here’s a bad way to convince Snyk “no really, this one is fine” in a way that robs your reviewer of making an informed choice about whether to accept the vulnerability.</p>

<h2 id="how-to-make-ignore-specific-issues-via-snyk">How to make ignore specific issues via .snyk?</h2>

<p>Tough luck, you can’t. You can <em>only</em> ignore files, not snyk code findings. <a href="https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues/exclude-files-and-ignore-issues-faqs#how-do-i-ignore-issues-and-vulnerabilities-in-code-sast-scans">https://docs.snyk.io/manage-risk/prioritize-issues-for-fixing/ignore-issues/exclude-files-and-ignore-issues-faqs#how-do-i-ignore-issues-and-vulnerabilities-in-code-sast-scans</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.</span>
<span class="c1"># unhelpful docs about this file format at https://docs.snyk.io/manage-risk/policies/the-.snyk-file#syntax-of-the-.snyk-file</span>
<span class="na">version</span><span class="pi">:</span> <span class="s">v1.25.1</span>
<span class="na">ignore</span><span class="pi">:</span> <span class="pi">{}</span> <span class="c1"># Can't be used as snyk don't support snyk code findings here</span>
<span class="na">patch</span><span class="pi">:</span> <span class="pi">{}</span>
<span class="na">exclude</span><span class="pi">:</span>
  <span class="na">global</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">scripts/bad-code.py</span> <span class="c1"># You just have to hope there are no other issues in this file</span>
</code></pre></div></div>

<h2 id="another-bad-alternative">Another bad alternative</h2>

<p>An admin can open the failed Snyk PR check, and click the “Mark as successful in SCM” button. If anything changes in the PR, any issues will be re-detected and an admin will have to click the button <em>again</em></p>]]></content><author><name></name></author><category term="Security" /><summary type="html"><![CDATA[Get Snyk Code to accept PRs]]></summary></entry><entry><title type="html">Github commit signing with Devin</title><link href="https://tales.fromprod.com/2025/182/github-commit-signing-with-devin.html" rel="alternate" type="text/html" title="Github commit signing with Devin" /><published>2025-07-01T12:00:00+00:00</published><updated>2025-07-01T12:00:00+00:00</updated><id>https://tales.fromprod.com/2025/182/github-commit-signing-with-devin</id><content type="html" xml:base="https://tales.fromprod.com/2025/182/github-commit-signing-with-devin.html"><![CDATA[<h1 id="github-commit-signing-with-devin">Github commit signing with Devin</h1>

<p>If you’re using Github, and want to use Devin with a repo that requires (signed commits)[https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits] then this guide is for you.</p>

<p>It assumes that you’ve installed Devin as a (Github App)[https://docs.devin.ai/integrations/gh] with write permissions to the relevant repo</p>

<h2 id="why-should-i-follow-this-guide-by-some-person-online">Why should I follow this guide by some person online?</h2>

<p>(The Devin guide)[https://docs.devin.ai/integrations/gh#commit-signing] requires</p>

<ul>
  <li>a separate Github account (which probably needs a company email account) ($)</li>
  <li>Generation of a GPG key, which is added to that Github account ($)</li>
  <li>a Github license for that account, so it can contribute to your repos ($$)</li>
  <li>extensive configuration of the Devin machine for each repo, with that special GPG key ($$)</li>
</ul>

<p>My process doesn’t.</p>

<h2 id="im-sold-how-do-i-do-this">I’m sold, how do I do this?</h2>

<ul>
  <li>Set up Devin for a repo as usual</li>
  <li>During “install deps” run <code class="language-plaintext highlighter-rouge">gh extension install kassett/gh-commit</code> to install the Github cli extension that will make the api calls to make the signed commits</li>
  <li>During <code class="language-plaintext highlighter-rouge">Repo Note</code> add the following notes, replacing YOUR_USER/YOUR_REPO with the real values</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>When creating a new branch, ensure to mark that it traces a remote branch
e.g.

`git checkout -b feat-123-make-fizzbop &amp;&amp; git branch --set-upstream-to=origin/feat-123-make-fizzbop `

When making a commit, use the following commands to make the commit, then pull it down from github. Replace feat-123-make-fizzbop with the real branch name, and the commit message with the desired message. This is required due to github commit signing. If you make commits locally they will *not* be verified and cannot be pushed up.


`export GH_REPO="YOUR_USER/YOUR_REPO" ; gh commit -B feat-123-make-fizzbop -A -m "chore: test commits from Devin"`


If you require assistance with this command, run `gh commit --help` and inspect the output
</code></pre></div></div>

<h2 id="why-does-this-work">Why does this work?</h2>

<p>Devin’s machine has the Github cli installed, and this has the permissions of the github app.
These permissions means that (via the cli) you can ask github to make a commit on your behalf, like the web editor. Github will sign these commits for you by design.</p>

<h2 id="useful-resources">Useful resources</h2>

<ul>
  <li>The thread that started this all https://github.com/cli/cli/issues/10365#issuecomment-2660677714</li>
  <li>The actual code making this work <a href="https://github.com/kassett/gh-commit">https://github.com/kassett/gh-commit</a></li>
</ul>]]></content><author><name></name></author><category term="APIs" /><category term="LLMs" /><summary type="html"><![CDATA[Github commit signing with Devin]]></summary></entry><entry><title type="html">Judgement from Gmail</title><link href="https://tales.fromprod.com/2025/162/judgement-from-gmail.html" rel="alternate" type="text/html" title="Judgement from Gmail" /><published>2025-06-11T16:00:00+00:00</published><updated>2025-06-11T16:00:00+00:00</updated><id>https://tales.fromprod.com/2025/162/judgement-from-gmail</id><content type="html" xml:base="https://tales.fromprod.com/2025/162/judgement-from-gmail.html"><![CDATA[<h1 id="judgement-from-gmail">Judgement from Gmail</h1>

<p>Say you’re writing an email, and want to make sure it’s not too spicy/unprofessional - in this world of LLMs you can get judgement whenever you want.</p>

<p>Google, for whatever reason, haven’t added proper Workspace Gemini support to <a href="https://developers.google.com/apps-script/">Appscript</a> so I’m using Cerebras for this example, since their inference speed is an order of magnitude faster than anything else and I don’t like spending time on emails</p>

<h2 id="steps-to-create">Steps to create</h2>

<ul>
  <li>Navigate to <a href="https://script.google.com/home">https://script.google.com/home</a> and click <code class="language-plaintext highlighter-rouge">New project</code></li>
  <li>Change the project name to something meaningful like <code class="language-plaintext highlighter-rouge">IsThisEmailAGoodIdea?</code></li>
  <li>Paste the Code.gs into the code editor</li>
  <li>Click the settings cog on the left
    <ul>
      <li>tick the “Show ‘appsscript.json’ manifest file in editor”</li>
      <li>in <code class="language-plaintext highlighter-rouge">Script properties</code> create a property <code class="language-plaintext highlighter-rouge">CEREBRAS_API_KEY</code> with the value of your Cerebras API key</li>
    </ul>
  </li>
  <li>Go back to the editor, and overwrite <code class="language-plaintext highlighter-rouge">appsscript.json</code> with the one below</li>
  <li>Click “Save project to Drive”</li>
  <li>Click “Deploy” -&gt; “Test Deployments” then “Application(s): Gmail” -&gt; Install</li>
</ul>

<h2 id="the-code">The code</h2>

<p>To be clear, not sponsored. Just wanted fast inference so I didn’t have to wait to test this out</p>

<p>This code was mostly generated by <code class="language-plaintext highlighter-rouge">claude-sonnet-4-20250514</code> via <a href="https://kagi.com/assistant">Kagi Assistant</a></p>

<p>Yes the caching strategy is pretty terrible, but good enough for this experiment</p>

<p>Code.gs</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * Gmail Add-on for Professional Email Tone Checker
 * Uses Cerebras Qwen2.5 model via OpenAI-compatible API
 */</span>

<span class="kd">function</span> <span class="nf">onGmailCompose</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nf">createComposeActionCard</span><span class="p">();</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">createComposeActionCard</span><span class="p">()</span> <span class="p">{</span>
  <span class="c1">// Check if we have cached results</span>
  <span class="kd">const</span> <span class="nx">cachedResult</span> <span class="o">=</span> <span class="nf">getCachedAnalysis</span><span class="p">();</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">cachedResult</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nf">createResultCard</span><span class="p">(</span><span class="nx">cachedResult</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span> <span class="c1">// true = minimized</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">card</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardBuilder</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardHeader</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setTitle</span><span class="p">(</span><span class="dl">'</span><span class="s1">Professional Tone Checker</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setImageUrl</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://www.gstatic.com/images/branding/product/1x/gmail_48dp.png</span><span class="dl">'</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newButtonSet</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">addButton</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextButton</span><span class="p">()</span>
          <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Check Professional Tone</span><span class="dl">'</span><span class="p">)</span>
          <span class="p">.</span><span class="nf">setOnClickAction</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newAction</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">setFunctionName</span><span class="p">(</span><span class="dl">'</span><span class="s1">checkEmailTone</span><span class="dl">'</span><span class="p">)))))</span>
    <span class="p">.</span><span class="nf">build</span><span class="p">();</span>

  <span class="k">return</span> <span class="p">[</span><span class="nx">card</span><span class="p">];</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">checkEmailTone</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">emailContent</span> <span class="o">=</span> <span class="nf">getEmailContent</span><span class="p">(</span><span class="nx">e</span><span class="p">);</span>

    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">emailContent</span><span class="p">.</span><span class="nx">subject</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">emailContent</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">return</span> <span class="nf">createErrorCard</span><span class="p">(</span><span class="dl">'</span><span class="s1">Please add content to your email before checking tone.</span><span class="dl">'</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="c1">// Create content hash for caching</span>
    <span class="kd">const</span> <span class="nx">contentHash</span> <span class="o">=</span> <span class="nf">createContentHash</span><span class="p">(</span><span class="nx">emailContent</span><span class="p">);</span>

    <span class="c1">// Check cache first</span>
    <span class="kd">let</span> <span class="nx">analysis</span> <span class="o">=</span> <span class="nf">getCachedAnalysisByHash</span><span class="p">(</span><span class="nx">contentHash</span><span class="p">);</span>

    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">analysis</span><span class="p">)</span> <span class="p">{</span>
      <span class="c1">// Analyze with Cerebras if not cached</span>
      <span class="nx">analysis</span> <span class="o">=</span> <span class="nf">analyzeWithCerebras</span><span class="p">(</span><span class="nx">emailContent</span><span class="p">);</span>

      <span class="c1">// Cache the result</span>
      <span class="nf">cacheAnalysis</span><span class="p">(</span><span class="nx">analysis</span><span class="p">,</span> <span class="nx">contentHash</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nf">createResultCard</span><span class="p">(</span><span class="nx">analysis</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span> <span class="c1">// false = expanded</span>

  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Error checking email tone:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="k">return</span> <span class="nf">createErrorCard</span><span class="p">(</span><span class="dl">'</span><span class="s1">Error analyzing email. Please try again.</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">toggleResultView</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">isMinimized</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">parameters</span><span class="p">?.</span><span class="nx">minimized</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">true</span><span class="dl">'</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">cachedResult</span> <span class="o">=</span> <span class="nf">getCachedAnalysis</span><span class="p">();</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">cachedResult</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nf">createResultCard</span><span class="p">(</span><span class="nx">cachedResult</span><span class="p">,</span> <span class="o">!</span><span class="nx">isMinimized</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="nf">createComposeActionCard</span><span class="p">();</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">clearCache</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="nf">clearUserCache</span><span class="p">();</span>
  <span class="k">return</span> <span class="nf">createComposeActionCard</span><span class="p">();</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">getEmailContent</span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">subject</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">formInput</span><span class="p">?.</span><span class="nx">subject</span> <span class="o">||</span> <span class="nx">e</span><span class="p">.</span><span class="nx">gmail</span><span class="p">?.</span><span class="nx">subject</span> <span class="o">||</span> <span class="dl">''</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">formInput</span><span class="p">?.</span><span class="nx">body</span> <span class="o">||</span> <span class="nx">e</span><span class="p">.</span><span class="nx">gmail</span><span class="p">?.</span><span class="nx">body</span> <span class="o">||</span> <span class="dl">''</span><span class="p">;</span>

  <span class="k">return</span> <span class="p">{</span>
    <span class="na">subject</span><span class="p">:</span> <span class="nx">subject</span><span class="p">,</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">body</span>
  <span class="p">};</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">createContentHash</span><span class="p">(</span><span class="nx">emailContent</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">content</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">emailContent</span><span class="p">.</span><span class="nx">subject</span><span class="p">}</span><span class="s2">|</span><span class="p">${</span><span class="nx">emailContent</span><span class="p">.</span><span class="nx">body</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
  <span class="k">return</span> <span class="nx">Utilities</span><span class="p">.</span><span class="nf">computeDigest</span><span class="p">(</span><span class="nx">Utilities</span><span class="p">.</span><span class="nx">DigestAlgorithm</span><span class="p">.</span><span class="nx">MD5</span><span class="p">,</span> <span class="nx">content</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="nx">byte</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="nx">byte</span> <span class="o">+</span> <span class="mi">256</span><span class="p">)</span> <span class="o">%</span> <span class="mi">256</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="nx">byte</span> <span class="o">=&gt;</span> <span class="nx">byte</span><span class="p">.</span><span class="nf">toString</span><span class="p">(</span><span class="mi">16</span><span class="p">).</span><span class="nf">padStart</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="dl">'</span><span class="s1">0</span><span class="dl">'</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">''</span><span class="p">);</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">cacheAnalysis</span><span class="p">(</span><span class="nx">analysis</span><span class="p">,</span> <span class="nx">contentHash</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">cache</span> <span class="o">=</span> <span class="nx">Utilities</span><span class="p">.</span><span class="nf">getUuid</span><span class="p">();</span> <span class="c1">// Generate unique cache key per user session</span>
  <span class="kd">const</span> <span class="nx">cacheData</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">analysis</span><span class="p">:</span> <span class="nx">analysis</span><span class="p">,</span>
    <span class="na">contentHash</span><span class="p">:</span> <span class="nx">contentHash</span><span class="p">,</span>
    <span class="na">timestamp</span><span class="p">:</span> <span class="k">new</span> <span class="nc">Date</span><span class="p">().</span><span class="nf">getTime</span><span class="p">()</span>
  <span class="p">};</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="nx">CacheService</span><span class="p">.</span><span class="nf">getUserCache</span><span class="p">().</span><span class="nf">put</span><span class="p">(</span><span class="dl">'</span><span class="s1">email_analysis</span><span class="dl">'</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">cacheData</span><span class="p">),</span> <span class="mi">3600</span><span class="p">);</span> <span class="c1">// 1 hour</span>
    <span class="nx">CacheService</span><span class="p">.</span><span class="nf">getUserCache</span><span class="p">().</span><span class="nf">put</span><span class="p">(</span><span class="dl">'</span><span class="s1">current_hash</span><span class="dl">'</span><span class="p">,</span> <span class="nx">contentHash</span><span class="p">,</span> <span class="mi">3600</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Cache error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">getCachedAnalysis</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">cached</span> <span class="o">=</span> <span class="nx">CacheService</span><span class="p">.</span><span class="nf">getUserCache</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">email_analysis</span><span class="dl">'</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">cached</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">cacheData</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">cached</span><span class="p">);</span>
      <span class="c1">// Check if cache is less than 1 hour old</span>
      <span class="k">if </span><span class="p">(</span><span class="k">new</span> <span class="nc">Date</span><span class="p">().</span><span class="nf">getTime</span><span class="p">()</span> <span class="o">-</span> <span class="nx">cacheData</span><span class="p">.</span><span class="nx">timestamp</span> <span class="o">&lt;</span> <span class="mi">3600000</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nx">cacheData</span><span class="p">.</span><span class="nx">analysis</span><span class="p">;</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Cache retrieval error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">getCachedAnalysisByHash</span><span class="p">(</span><span class="nx">contentHash</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">cached</span> <span class="o">=</span> <span class="nx">CacheService</span><span class="p">.</span><span class="nf">getUserCache</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">email_analysis</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">currentHash</span> <span class="o">=</span> <span class="nx">CacheService</span><span class="p">.</span><span class="nf">getUserCache</span><span class="p">().</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">current_hash</span><span class="dl">'</span><span class="p">);</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">cached</span> <span class="o">&amp;&amp;</span> <span class="nx">currentHash</span> <span class="o">===</span> <span class="nx">contentHash</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">cacheData</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">cached</span><span class="p">);</span>
      <span class="k">return</span> <span class="nx">cacheData</span><span class="p">.</span><span class="nx">analysis</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Cache hash retrieval error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">clearUserCache</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="nx">CacheService</span><span class="p">.</span><span class="nf">getUserCache</span><span class="p">().</span><span class="nf">removeAll</span><span class="p">([</span><span class="dl">'</span><span class="s1">email_analysis</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">current_hash</span><span class="dl">'</span><span class="p">]);</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Cache clear error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">analyzeWithCerebras</span><span class="p">(</span><span class="nx">emailContent</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">prompt</span> <span class="o">=</span> <span class="s2">`Analyze this email for professional tone and business appropriateness:

Subject: </span><span class="p">${</span><span class="nx">emailContent</span><span class="p">.</span><span class="nx">subject</span><span class="p">}</span><span class="s2">
Body: </span><span class="p">${</span><span class="nx">emailContent</span><span class="p">.</span><span class="nx">body</span><span class="p">}</span><span class="s2">

Provide analysis in this exact JSON format:
{
  "score": [1-10 number],
  "tone": "[brief tone description]",
  "issues": ["issue1", "issue2"],
  "suggestions": ["suggestion1", "suggestion2"],
  "positives": ["positive1", "positive2"]
}

Focus on:
- Professional language usage
- Appropriate formality level
- Clear communication
- Respectful tone
- Business etiquette`</span><span class="p">;</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="nf">callCerebrasAPI</span><span class="p">(</span><span class="nx">prompt</span><span class="p">);</span>
    <span class="k">return</span> <span class="nf">parseCerebrasResponse</span><span class="p">(</span><span class="nx">response</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Cerebras API error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Failed to analyze email with AI</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">callCerebrasAPI</span><span class="p">(</span><span class="nx">prompt</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">apiKey</span> <span class="o">=</span> <span class="nx">PropertiesService</span><span class="p">.</span><span class="nf">getScriptProperties</span><span class="p">().</span><span class="nf">getProperty</span><span class="p">(</span><span class="dl">'</span><span class="s1">CEREBRAS_API_KEY</span><span class="dl">'</span><span class="p">);</span>

  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">apiKey</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Cerebras API key not configured</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">https://api.cerebras.ai/v1/chat/completions</span><span class="dl">'</span><span class="p">;</span>

  <span class="kd">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">model</span><span class="p">:</span> <span class="dl">'</span><span class="s1">qwen-3-32b</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">messages</span><span class="p">:</span> <span class="p">[</span>
      <span class="p">{</span>
        <span class="na">role</span><span class="p">:</span> <span class="dl">'</span><span class="s1">system</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">content</span><span class="p">:</span> <span class="dl">'</span><span class="s1">You are a professional communication expert. Analyze emails for business appropriateness and provide constructive feedback in JSON format only.</span><span class="dl">'</span>
      <span class="p">},</span>
      <span class="p">{</span>
        <span class="na">role</span><span class="p">:</span> <span class="dl">'</span><span class="s1">user</span><span class="dl">'</span><span class="p">,</span>
        <span class="na">content</span><span class="p">:</span> <span class="nx">prompt</span>
      <span class="p">}</span>
    <span class="p">],</span>
    <span class="na">max_tokens</span><span class="p">:</span> <span class="mi">1000</span><span class="p">,</span>
    <span class="na">temperature</span><span class="p">:</span> <span class="mf">0.7</span><span class="p">,</span>
    <span class="na">top_p</span><span class="p">:</span> <span class="mf">0.95</span>
  <span class="p">};</span>

  <span class="kd">const</span> <span class="nx">options</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
      <span class="dl">'</span><span class="s1">Authorization</span><span class="dl">'</span><span class="p">:</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">apiKey</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
      <span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/json</span><span class="dl">'</span><span class="p">,</span>
    <span class="p">},</span>
    <span class="na">payload</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">payload</span><span class="p">)</span>
  <span class="p">};</span>

  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="nx">UrlFetchApp</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">options</span><span class="p">);</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nf">getResponseCode</span><span class="p">()</span> <span class="o">!==</span> <span class="mi">200</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">errorText</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nf">getContentText</span><span class="p">();</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">API Error Response:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">errorText</span><span class="p">);</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`API request failed: </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nf">getResponseCode</span><span class="p">()}</span><span class="s2">`</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nf">getContentText</span><span class="p">());</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">parseCerebrasResponse</span><span class="p">(</span><span class="nx">response</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">content</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">message</span><span class="p">.</span><span class="nx">content</span><span class="p">.</span><span class="nf">trim</span><span class="p">();</span>

    <span class="kd">let</span> <span class="nx">jsonMatch</span> <span class="o">=</span> <span class="nx">content</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/</span><span class="se">\{[\s\S]</span><span class="sr">*</span><span class="se">\}</span><span class="sr">/</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">jsonMatch</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">return</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">jsonMatch</span><span class="p">[</span><span class="mi">0</span><span class="p">]);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nf">parseTextResponse</span><span class="p">(</span><span class="nx">content</span><span class="p">);</span>

  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Parse error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Failed to parse AI response</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">parseTextResponse</span><span class="p">(</span><span class="nx">text</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">score</span><span class="p">:</span> <span class="mi">7</span><span class="p">,</span>
    <span class="na">tone</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Professional</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">issues</span><span class="p">:</span> <span class="p">[],</span>
    <span class="na">suggestions</span><span class="p">:</span> <span class="p">[],</span>
    <span class="na">positives</span><span class="p">:</span> <span class="p">[]</span>
  <span class="p">};</span>

  <span class="kd">const</span> <span class="nx">scoreMatch</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/score</span><span class="se">[</span><span class="sr">:</span><span class="se">\s]</span><span class="sr">*</span><span class="se">(\d</span><span class="sr">+</span><span class="se">)</span><span class="sr">/i</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">scoreMatch</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">result</span><span class="p">.</span><span class="nx">score</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">min</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="nf">parseInt</span><span class="p">(</span><span class="nx">scoreMatch</span><span class="p">[</span><span class="mi">1</span><span class="p">])));</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">toneMatch</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/tone</span><span class="se">[</span><span class="sr">:</span><span class="se">\s]</span><span class="sr">*</span><span class="se">[</span><span class="sr">"</span><span class="se">\']?([^</span><span class="sr">"</span><span class="se">\'\n]</span><span class="sr">+</span><span class="se">)[</span><span class="sr">"</span><span class="se">\']?</span><span class="sr">/i</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">toneMatch</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">result</span><span class="p">.</span><span class="nx">tone</span> <span class="o">=</span> <span class="nx">toneMatch</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nf">trim</span><span class="p">();</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">issuesSection</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/issues</span><span class="se">?[</span><span class="sr">:</span><span class="se">\s]</span><span class="sr">*</span><span class="se">\[(</span><span class="sr">.*</span><span class="se">?)\]</span><span class="sr">/i</span><span class="nx">s</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">issuesSection</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">result</span><span class="p">.</span><span class="nx">issues</span> <span class="o">=</span> <span class="nx">issuesSection</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nf">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">"</span><span class="se">\'\[\]]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">).</span><span class="nf">trim</span><span class="p">()).</span><span class="nf">filter</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">suggestionsSection</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/suggestions</span><span class="se">?[</span><span class="sr">:</span><span class="se">\s]</span><span class="sr">*</span><span class="se">\[(</span><span class="sr">.*</span><span class="se">?)\]</span><span class="sr">/i</span><span class="nx">s</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">suggestionsSection</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">result</span><span class="p">.</span><span class="nx">suggestions</span> <span class="o">=</span> <span class="nx">suggestionsSection</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nf">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">"</span><span class="se">\'\[\]]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">).</span><span class="nf">trim</span><span class="p">()).</span><span class="nf">filter</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">positivesSection</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/positives</span><span class="se">?[</span><span class="sr">:</span><span class="se">\s]</span><span class="sr">*</span><span class="se">\[(</span><span class="sr">.*</span><span class="se">?)\]</span><span class="sr">/i</span><span class="nx">s</span><span class="p">);</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">positivesSection</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">result</span><span class="p">.</span><span class="nx">positives</span> <span class="o">=</span> <span class="nx">positivesSection</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nf">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">).</span><span class="nf">map</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">[</span><span class="sr">"</span><span class="se">\'\[\]]</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">''</span><span class="p">).</span><span class="nf">trim</span><span class="p">()).</span><span class="nf">filter</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="k">return</span> <span class="nx">result</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">createResultCard</span><span class="p">(</span><span class="nx">analysis</span><span class="p">,</span> <span class="nx">isMinimized</span> <span class="o">=</span> <span class="kc">false</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">card</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardBuilder</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardHeader</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setTitle</span><span class="p">(</span><span class="dl">'</span><span class="s1">Professional Tone Analysis</span><span class="dl">'</span><span class="p">));</span>

  <span class="c1">// Always show score and basic info</span>
  <span class="kd">const</span> <span class="nx">mainSection</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newKeyValue</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setTopLabel</span><span class="p">(</span><span class="dl">'</span><span class="s1">Professional Score</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setContent</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">analysis</span><span class="p">.</span><span class="nx">score</span><span class="p">}</span><span class="s2">/10`</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setIcon</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nx">Icon</span><span class="p">.</span><span class="nx">STAR</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newKeyValue</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setTopLabel</span><span class="p">(</span><span class="dl">'</span><span class="s1">Tone Assessment</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setContent</span><span class="p">(</span><span class="nx">analysis</span><span class="p">.</span><span class="nx">tone</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">Professional</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setIcon</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nx">Icon</span><span class="p">.</span><span class="nx">DESCRIPTION</span><span class="p">));</span>

  <span class="nx">card</span><span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">mainSection</span><span class="p">);</span>

  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">isMinimized</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Add detailed sections when expanded</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">analysis</span><span class="p">.</span><span class="nx">issues</span> <span class="o">&amp;&amp;</span> <span class="nx">analysis</span><span class="p">.</span><span class="nx">issues</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">issuesSection</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">⚠️ Areas for Improvement</span><span class="dl">'</span><span class="p">);</span>

      <span class="nx">analysis</span><span class="p">.</span><span class="nx">issues</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">3</span><span class="p">).</span><span class="nf">forEach</span><span class="p">(</span><span class="nx">issue</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">issue</span> <span class="o">&amp;&amp;</span> <span class="nx">issue</span><span class="p">.</span><span class="nf">trim</span><span class="p">())</span> <span class="p">{</span>
          <span class="nx">issuesSection</span><span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextParagraph</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="s2">`• </span><span class="p">${</span><span class="nx">issue</span><span class="p">.</span><span class="nf">trim</span><span class="p">()}</span><span class="s2">`</span><span class="p">));</span>
        <span class="p">}</span>
      <span class="p">});</span>

      <span class="nx">card</span><span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">issuesSection</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">analysis</span><span class="p">.</span><span class="nx">suggestions</span> <span class="o">&amp;&amp;</span> <span class="nx">analysis</span><span class="p">.</span><span class="nx">suggestions</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">suggestionsSection</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">💡 Suggestions</span><span class="dl">'</span><span class="p">);</span>

      <span class="nx">analysis</span><span class="p">.</span><span class="nx">suggestions</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">3</span><span class="p">).</span><span class="nf">forEach</span><span class="p">(</span><span class="nx">suggestion</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">suggestion</span> <span class="o">&amp;&amp;</span> <span class="nx">suggestion</span><span class="p">.</span><span class="nf">trim</span><span class="p">())</span> <span class="p">{</span>
          <span class="nx">suggestionsSection</span><span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextParagraph</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="s2">`• </span><span class="p">${</span><span class="nx">suggestion</span><span class="p">.</span><span class="nf">trim</span><span class="p">()}</span><span class="s2">`</span><span class="p">));</span>
        <span class="p">}</span>
      <span class="p">});</span>

      <span class="nx">card</span><span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">suggestionsSection</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">analysis</span><span class="p">.</span><span class="nx">positives</span> <span class="o">&amp;&amp;</span> <span class="nx">analysis</span><span class="p">.</span><span class="nx">positives</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">positivesSection</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">✅ Positive Aspects</span><span class="dl">'</span><span class="p">);</span>

      <span class="nx">analysis</span><span class="p">.</span><span class="nx">positives</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">3</span><span class="p">).</span><span class="nf">forEach</span><span class="p">(</span><span class="nx">positive</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">positive</span> <span class="o">&amp;&amp;</span> <span class="nx">positive</span><span class="p">.</span><span class="nf">trim</span><span class="p">())</span> <span class="p">{</span>
          <span class="nx">positivesSection</span><span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextParagraph</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="s2">`• </span><span class="p">${</span><span class="nx">positive</span><span class="p">.</span><span class="nf">trim</span><span class="p">()}</span><span class="s2">`</span><span class="p">));</span>
        <span class="p">}</span>
      <span class="p">});</span>

      <span class="nx">card</span><span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">positivesSection</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="c1">// Control buttons</span>
  <span class="kd">const</span> <span class="nx">buttonSection</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">buttonSet</span> <span class="o">=</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newButtonSet</span><span class="p">();</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">isMinimized</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">buttonSet</span><span class="p">.</span><span class="nf">addButton</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextButton</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Show Details</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setOnClickAction</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newAction</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setFunctionName</span><span class="p">(</span><span class="dl">'</span><span class="s1">toggleResultView</span><span class="dl">'</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">setParameters</span><span class="p">({</span><span class="na">minimized</span><span class="p">:</span> <span class="dl">'</span><span class="s1">true</span><span class="dl">'</span><span class="p">})));</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="nx">buttonSet</span><span class="p">.</span><span class="nf">addButton</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextButton</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Minimize</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setOnClickAction</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newAction</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setFunctionName</span><span class="p">(</span><span class="dl">'</span><span class="s1">toggleResultView</span><span class="dl">'</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">setParameters</span><span class="p">({</span><span class="na">minimized</span><span class="p">:</span> <span class="dl">'</span><span class="s1">false</span><span class="dl">'</span><span class="p">})));</span>
  <span class="p">}</span>

  <span class="nx">buttonSet</span><span class="p">.</span><span class="nf">addButton</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextButton</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Analyze Again</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">setOnClickAction</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newAction</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setFunctionName</span><span class="p">(</span><span class="dl">'</span><span class="s1">checkEmailTone</span><span class="dl">'</span><span class="p">)))</span>
    <span class="p">.</span><span class="nf">addButton</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextButton</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Clear Cache</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">setOnClickAction</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newAction</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setFunctionName</span><span class="p">(</span><span class="dl">'</span><span class="s1">clearCache</span><span class="dl">'</span><span class="p">)));</span>

  <span class="nx">buttonSection</span><span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">buttonSet</span><span class="p">);</span>
  <span class="nx">card</span><span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">buttonSection</span><span class="p">);</span>

  <span class="k">return</span> <span class="nx">card</span><span class="p">.</span><span class="nf">build</span><span class="p">();</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">createErrorCard</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardBuilder</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardHeader</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">setTitle</span><span class="p">(</span><span class="dl">'</span><span class="s1">Error</span><span class="dl">'</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">addSection</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newCardSection</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextParagraph</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="nx">message</span><span class="p">))</span>
      <span class="p">.</span><span class="nf">addWidget</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newButtonSet</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">addButton</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newTextButton</span><span class="p">()</span>
          <span class="p">.</span><span class="nf">setText</span><span class="p">(</span><span class="dl">'</span><span class="s1">Try Again</span><span class="dl">'</span><span class="p">)</span>
          <span class="p">.</span><span class="nf">setOnClickAction</span><span class="p">(</span><span class="nx">CardService</span><span class="p">.</span><span class="nf">newAction</span><span class="p">()</span>
            <span class="p">.</span><span class="nf">setFunctionName</span><span class="p">(</span><span class="dl">'</span><span class="s1">checkEmailTone</span><span class="dl">'</span><span class="p">)))))</span>
    <span class="p">.</span><span class="nf">build</span><span class="p">();</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">testCerebrasAPI</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">testEmail</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">subject</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Test Email</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">body</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Hey there! Just wanted to check if we can meet tomorrow. Let me know!</span><span class="dl">'</span>
  <span class="p">};</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="nf">analyzeWithCerebras</span><span class="p">(</span><span class="nx">testEmail</span><span class="p">);</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Test result:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">result</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">result</span><span class="p">;</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Test failed:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">error</span><span class="p">.</span><span class="nf">toString</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div>

<p>appscript.json</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"timeZone"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Etc/UTC"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"enabledAdvancedServices"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"exceptionLogging"</span><span class="p">:</span><span class="w"> </span><span class="s2">"NONE"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"runtimeVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"V8"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"gmail"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Professional Tone Checker"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"logoUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.gstatic.com/images/branding/product/1x/gmail_48dp.png"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"contextualTriggers"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"unconditional"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span><span class="w">
        </span><span class="nl">"onTriggerFunction"</span><span class="p">:</span><span class="w"> </span><span class="s2">"onGmailCompose"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"composeTrigger"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"selectActions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Check Professional Tone"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"runFunction"</span><span class="p">:</span><span class="w"> </span><span class="s2">"checkEmailTone"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">],</span><span class="w">
      </span><span class="nl">"draftAccess"</span><span class="p">:</span><span class="w"> </span><span class="s2">"METADATA"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"oauthScopes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"https://www.googleapis.com/auth/gmail.addons.current.message.readonly"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"https://www.googleapis.com/auth/gmail.addons.current.action.compose"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"https://www.googleapis.com/auth/script.external_request"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"https://www.googleapis.com/auth/gmail.addons.execute"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"https://www.googleapis.com/auth/gmail.addons.current.message.metadata"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="how-to-use">How to use</h3>

<ul>
  <li>Open a drafted email</li>
  <li>click the vertical … more options button</li>
  <li>Select “Check Professional Tone”
    <ul>
      <li>First time, you’ll be prompted to provide oauth permissions to the appscript script.</li>
    </ul>
  </li>
  <li>A card will show up, with guidance about your email</li>
</ul>

<h3 id="next-steps-for-users">Next steps for users</h3>

<p>Tune what exactly you want from the guidance by tweaking the prompt and display details in <code class="language-plaintext highlighter-rouge">Code.gs</code></p>]]></content><author><name></name></author><category term="APIs" /><category term="LLMs" /><summary type="html"><![CDATA[Judgement from Gmail]]></summary></entry><entry><title type="html">Have Claude yell at you with MCP and bash</title><link href="https://tales.fromprod.com/2025/140/have-claude-yell-at-you-with-mcp-and-bash.html" rel="alternate" type="text/html" title="Have Claude yell at you with MCP and bash" /><published>2025-05-20T12:00:00+00:00</published><updated>2025-05-20T12:00:00+00:00</updated><id>https://tales.fromprod.com/2025/140/have-claude-yell-at-you-with-mcp-and-bash</id><content type="html" xml:base="https://tales.fromprod.com/2025/140/have-claude-yell-at-you-with-mcp-and-bash.html"><![CDATA[<h1 id="have-claude-yell-at-you-with-mcp-and-bash">Have Claude yell at you with MCP and bash</h1>

<p>There’s a lot of excitement currently with Anthropic’s Model Context Protocol <a href="https://modelcontextprotocol.io/introduction">MCP</a> and “servers” to allow language models to do more stuff.</p>

<p>Their tutorials use libraries with a <em>lot</em> of code (the npm one is <a href="https://www.npmjs.com/package/@modelcontextprotocol/sdk">5MB!</a>)</p>

<p>What if there was a simpler way for an infra person to get started?</p>

<p>That’s right, which if it could be done with ~ 35 lines of bash?</p>

<h2 id="whats-needed">What’s needed?</h2>

<p>You need something that runs, and accepts text on stdin - and fulfils an <a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2024-11-05/schema.ts">API</a></p>

<h2 id="so-how-can-i-get-an-llm-to-yell-at-me">So, how can I get an LLM to yell at me?</h2>

<p>First, write the “say-mcp” script, which will use “say” on macos for text to speech (tts) to say whatever string the language model sent to it.</p>

<p>For this example, write the following to <code class="language-plaintext highlighter-rouge">/tmp/say-mcp.sh</code> and <code class="language-plaintext highlighter-rouge">chmod +x /tmp/say-mcp.sh</code> it to ensure it can be executed</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="c">#!/bin/bash</span>

<span class="nb">echo</span> <span class="s2">"Starting mcp_add.sh"</span> <span class="o">&gt;&gt;</span> /tmp/say-mcp-requests.log

<span class="k">while </span><span class="nb">read</span> <span class="nt">-r</span> line<span class="p">;</span> <span class="k">do
    </span><span class="nb">echo</span> <span class="nv">$line</span> <span class="o">&gt;&gt;</span> /tmp/say-mcp-requests.log
    <span class="c"># Parse JSON input using jq</span>
    <span class="nv">method</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$line</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.method'</span> 2&gt;/dev/null<span class="si">)</span>
    <span class="nb">id</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$line</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.id'</span> 2&gt;/dev/null<span class="si">)</span>
    <span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$method</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"initialize"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":'</span><span class="s2">"</span><span class="nv">$id</span><span class="s2">"</span><span class="s1">',"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"say","version":"0.0.1"}}}'</span>

    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$method</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"notifications/initialized"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
        : <span class="c">#do nothing</span>

    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$method</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"tools/list"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":'</span><span class="s2">"</span><span class="nv">$id</span><span class="s2">"</span><span class="s1">',"result":{"tools":[{"name":"say","description":"Says the provided string with text to speech.\n\nArgs:\n    text\n","inputSchema":{"properties":{"text":{"title":"Text","type":"string"}},"required":["text"],"type":"object"}}]}}'</span>

    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$method</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"resources/list"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":'</span><span class="s2">"</span><span class="nv">$id</span><span class="s2">"</span><span class="s1">',"result":{"resources":[]}}'</span>

    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$method</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"prompts/list"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":'</span><span class="s2">"</span><span class="nv">$id</span><span class="s2">"</span><span class="s1">',"result":{"prompts":[]}}'</span>

    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$method</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"tools/call"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
        <span class="c">#{"method":"tools/call","params":{"name":"addition","arguments":{"num1":"1","num2":"2"}},"jsonrpc":"2.0","id":20}</span>
        <span class="nv">tool_method</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$line</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.params.name'</span> 2&gt;/dev/null<span class="si">)</span>
        <span class="nv">speech</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$line</span><span class="s2">"</span> | jq <span class="nt">-r</span> <span class="s1">'.params.arguments.text'</span> 2&gt;/dev/null<span class="si">)</span>
        say <span class="s2">"</span><span class="k">${</span><span class="nv">speech</span><span class="k">}</span><span class="s2">"</span>
        <span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":'</span><span class="s2">"</span><span class="nv">$id</span><span class="s2">"</span><span class="s1">',"result":{"content":[{"type":"text","text":"said ${speech}"}],"isError":false}}'</span>

    <span class="k">else
        </span><span class="nb">echo</span> <span class="s1">'{"jsonrpc":"2.0","id":'</span><span class="s2">"</span><span class="nv">$id</span><span class="s2">"</span><span class="s1">',"error":{"code":-32601,"message":"Method not found"}}'</span>
    <span class="k">fi
done</span> <span class="o">||</span> <span class="nb">break</span>
</code></pre></div></div>

<p>Now we have our “MCP server” we need to tell Claude to use it.</p>

<p>Open Claude settings, go to developer, then click “Edit Config”</p>

<p>Add the “mcpServers” section, the important part is the “say” object, and the command needs to be the path of the shell script</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"say"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/tmp/say-mcp.sh"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Restart Claude, and you should now have the ability to ask Claude to say/yell stuff at you.</p>

<h2 id="credits">Credits</h2>

<p>Thanks to <a href="https://github.com/antonum/mcp-server-bash/tree/main">https://github.com/antonum/mcp-server-bash/tree/main</a> for the original idea</p>]]></content><author><name></name></author><category term="APIs" /><category term="Claude" /><category term="MCP" /><summary type="html"><![CDATA[Have Claude yell at you with MCP and bash]]></summary></entry><entry><title type="html">Using Playwright with Github Actions and Auth</title><link href="https://tales.fromprod.com/2025/066/using-playwright-with-github-actions-and-auth.html" rel="alternate" type="text/html" title="Using Playwright with Github Actions and Auth" /><published>2025-03-07T12:00:00+00:00</published><updated>2025-03-07T12:00:00+00:00</updated><id>https://tales.fromprod.com/2025/066/using-playwright-with-github-actions-and-auth</id><content type="html" xml:base="https://tales.fromprod.com/2025/066/using-playwright-with-github-actions-and-auth.html"><![CDATA[<h1 id="using-playwright-with-github-actions-and-auth">Using Playwright with Github Actions and Auth</h1>

<p>You’ve got a webapp now and want to make sure it works. You’ve done the right thing of requiring auth for all users and <a href="https://en.wikipedia.org/wiki/Time-based_one-time_password">TOTP</a> so your bots also have to use this.</p>

<p>You’ve chosen playwright, and are wondering how to make</p>

<ul>
  <li>The various browsers use the same cookies/session for auth</li>
  <li>avoid having to download all the various browsers every time</li>
</ul>

<h2 id="configuration">Configuration</h2>

<p>TOTPs can only be used, and for a limited time period (it’s in the name)</p>

<p>Due to this, we want to authenticate once, and then re-use those cookies/etc for the various browsers for this bot user</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// e2e-tests/external-users/auth.setup.ts</span>

<span class="k">import</span> <span class="p">{</span> <span class="nx">test</span> <span class="kd">as </span><span class="nx">setup</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@playwright/test</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">promises</span> <span class="kd">as </span><span class="nx">fs</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">fs</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">path</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">path</span><span class="dl">"</span><span class="p">;</span>

<span class="k">import</span> <span class="p">{</span> <span class="nx">login</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">../lib/login</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">authFile</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">import</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">dirname</span><span class="p">,</span> <span class="dl">"</span><span class="s2">external-users.auth.json</span><span class="dl">"</span><span class="p">);</span>

<span class="nf">setup</span><span class="p">(</span><span class="dl">"</span><span class="s2">authenticate</span><span class="dl">"</span><span class="p">,</span> <span class="k">async </span><span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// login is my custom function to do all the authentication/totp goodness based on https://playwrightsolutions.com/playwright-login-test-with-2-factor-authentication-2fa-enabled/</span>
  <span class="c1">// login will still need to have a backoff in case multiple github actions run at the same time, and deal with retries</span>
  <span class="k">await</span> <span class="nf">login</span><span class="p">(</span><span class="nx">page</span><span class="p">,</span> <span class="dl">"</span><span class="s2">USERNAME</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">EMAIL</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">PASSWORD</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">TOTP_SEED</span><span class="dl">"</span><span class="p">);</span>

  <span class="k">await</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">mkdir</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nf">dirname</span><span class="p">(</span><span class="nx">authFile</span><span class="p">),</span> <span class="p">{</span> <span class="na">recursive</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{});</span>

  <span class="c1">// Save out the cookies/etc for use by *all* browsers</span>
  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">context</span><span class="p">().</span><span class="nf">storageState</span><span class="p">({</span> <span class="na">path</span><span class="p">:</span> <span class="nx">authFile</span> <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Now for the actual playwright configuration, main useful part is <code class="language-plaintext highlighter-rouge">dependencies: ["setup"],</code> and <code class="language-plaintext highlighter-rouge">storageState: "e2e-tests/external-users/external-users.auth.json",</code> which means the auth is only done once and then re-used by the other browsers</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// playwright.config.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">defineConfig</span><span class="p">,</span> <span class="nx">devices</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@playwright/test</span><span class="dl">"</span><span class="p">;</span>

<span class="cm">/**
 * See https://playwright.dev/docs/test-configuration.
 */</span>
<span class="c1">// eslint-disable-next-line no-restricted-exports</span>
<span class="k">export</span> <span class="k">default</span> <span class="nf">defineConfig</span><span class="p">({</span>
  <span class="na">testDir</span><span class="p">:</span> <span class="dl">"</span><span class="s2">./e2e-tests</span><span class="dl">"</span><span class="p">,</span>
  <span class="cm">/* Run tests in files in parallel */</span>
  <span class="na">fullyParallel</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="cm">/* Fail the build on CI if you accidentally left test.only in the source code. */</span>
  <span class="na">forbidOnly</span><span class="p">:</span> <span class="o">!!</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CI</span><span class="p">,</span>
  <span class="cm">/* Retry on CI only */</span>
  <span class="na">retries</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CI</span> <span class="p">?</span> <span class="mi">2</span> <span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
  <span class="c1">// 10 minutes since LLMs can be slow</span>
  <span class="na">timeout</span><span class="p">:</span> <span class="mi">600000</span><span class="p">,</span>
  <span class="cm">/* Reporter to use. See https://playwright.dev/docs/test-reporters */</span>
  <span class="na">reporter</span><span class="p">:</span> <span class="dl">"</span><span class="s2">html</span><span class="dl">"</span><span class="p">,</span>
  <span class="cm">/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">{</span>
    <span class="cm">/* Base URL to use in actions like `await page.goto('/')`. */</span>
    <span class="c1">// baseURL: 'http://127.0.0.1:3000',</span>

    <span class="cm">/* Record trace for failed tests. See https://playwright.dev/docs/trace-viewer */</span>
    <span class="na">trace</span><span class="p">:</span> <span class="dl">"</span><span class="s2">retain-on-failure</span><span class="dl">"</span><span class="p">,</span>

    <span class="c1">// Record video for failed tests</span>
    <span class="na">video</span><span class="p">:</span> <span class="dl">"</span><span class="s2">retain-on-failure</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">},</span>

  <span class="cm">/* Configure projects for major browsers */</span>
  <span class="na">projects</span><span class="p">:</span> <span class="p">[</span>
    <span class="p">{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">setup</span><span class="dl">"</span><span class="p">,</span> <span class="na">testMatch</span><span class="p">:</span> <span class="sr">/.*</span><span class="se">\.</span><span class="sr">setup</span><span class="se">\.</span><span class="sr">ts/</span> <span class="p">},</span>
    <span class="p">{</span>
      <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">external-users-chromium</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">use</span><span class="p">:</span> <span class="p">{</span>
        <span class="p">...</span><span class="nx">devices</span><span class="p">[</span><span class="dl">"</span><span class="s2">Desktop Chrome</span><span class="dl">"</span><span class="p">],</span>
        <span class="na">storageState</span><span class="p">:</span> <span class="dl">"</span><span class="s2">e2e-tests/external-users/external-users.auth.json</span><span class="dl">"</span><span class="p">,</span>
      <span class="p">},</span>
      <span class="na">testMatch</span><span class="p">:</span> <span class="sr">/external-users</span><span class="se">\/</span><span class="sr">.*</span><span class="se">\.</span><span class="sr">spec</span><span class="se">\.</span><span class="sr">ts/</span><span class="p">,</span>
      <span class="na">dependencies</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">setup</span><span class="dl">"</span><span class="p">],</span>
    <span class="p">},</span>

    <span class="p">{</span>
      <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">external-users-firefox</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">use</span><span class="p">:</span> <span class="p">{</span>
        <span class="p">...</span><span class="nx">devices</span><span class="p">[</span><span class="dl">"</span><span class="s2">Desktop Firefox</span><span class="dl">"</span><span class="p">],</span>
        <span class="na">storageState</span><span class="p">:</span> <span class="dl">"</span><span class="s2">e2e-tests/external-users/external-users.auth.json</span><span class="dl">"</span><span class="p">,</span>
      <span class="p">},</span>
      <span class="na">testMatch</span><span class="p">:</span> <span class="sr">/external-users</span><span class="se">\/</span><span class="sr">.*</span><span class="se">\.</span><span class="sr">spec</span><span class="se">\.</span><span class="sr">ts/</span><span class="p">,</span>
      <span class="na">dependencies</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">setup</span><span class="dl">"</span><span class="p">],</span>
    <span class="p">},</span>

    <span class="p">{</span>
      <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">external-users-webkit</span><span class="dl">"</span><span class="p">,</span>
      <span class="na">use</span><span class="p">:</span> <span class="p">{</span>
        <span class="p">...</span><span class="nx">devices</span><span class="p">[</span><span class="dl">"</span><span class="s2">Desktop Safari</span><span class="dl">"</span><span class="p">],</span>
        <span class="na">storageState</span><span class="p">:</span> <span class="dl">"</span><span class="s2">e2e-tests/external-users/external-users.auth.json</span><span class="dl">"</span><span class="p">,</span>
      <span class="p">},</span>
      <span class="na">testMatch</span><span class="p">:</span> <span class="sr">/external-users</span><span class="se">\/</span><span class="sr">.*</span><span class="se">\.</span><span class="sr">spec</span><span class="se">\.</span><span class="sr">ts/</span><span class="p">,</span>
      <span class="na">dependencies</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">setup</span><span class="dl">"</span><span class="p">],</span>
    <span class="p">},</span>
  <span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="caching-and-github-actions">Caching and Github Actions</h2>

<p>Here is my customised version of the initial playwright Github Action. The main change is</p>

<ul>
  <li>Cache the browsers downloaded, based on playwright version and configuration</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Scheduled Playwright Tests</span>
<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s2">"</span><span class="s">*/30</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*"</span> <span class="c1"># Run every 30 minutes</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span> <span class="pi">{}</span>
<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">playwright</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">test</span><span class="pi">:</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">15</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="s">lts/*</span>
          <span class="na">cache</span><span class="pi">:</span> <span class="s2">"</span><span class="s">npm"</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install dependencies</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npm ci</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Get installed Playwright version</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">playwright-version</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" &gt;&gt; $GITHUB_ENV</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Get hashed Playwright configuration</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">echo "PLAYWRIGHT_CONFIG_HASH=$(node -e "console.log(require('crypto').createHash('sha256').update(require('fs').readFileSync('./playwright.config.ts', 'utf8')).digest('hex'))")" &gt;&gt; $GITHUB_ENV</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Restore cached playwright binaries</span>
        <span class="c1"># From https://playwrightsolutions.com/playwright-github-action-to-cache-the-browser-binaries/</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/cache/restore@v4</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">playwright-read-cache</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">path</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">~/.cache/ms-playwright</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s">${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-${{ env.PLAYWRIGHT_CONFIG_HASH }}</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install Playwright Browsers</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npx playwright install --with-deps</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Cache Playwright Browsers</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">steps.playwright-read-cache.outputs.cache-hit != 'true'</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/cache/save@v4</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">playwright-write-cache</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">path</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">~/.cache/ms-playwright</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s">${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}-${{ env.PLAYWRIGHT_CONFIG_HASH }}</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run Playwright tests</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">npx playwright test</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">USERNAME</span><span class="pi">:</span> <span class="s">${{ secrets.USERNAME }}</span>
          <span class="na">EMAIL</span><span class="pi">:</span> <span class="s">${{ secrets.EMAIL }}</span>
          <span class="na">PASSWORD</span><span class="pi">:</span> <span class="s">${{ secrets.PASSWORD }}</span>
          <span class="na">TOTP_SEED</span><span class="pi">:</span> <span class="s">${{ secrets.TOTP_SEED }}</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">${{ !cancelled() }}</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">playwright-report</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">playwright-report/</span>
          <span class="na">retention-days</span><span class="pi">:</span> <span class="m">8</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="APIs" /><category term="Playwright" /><category term="Github" /><summary type="html"><![CDATA[Using Playwright with Github Actions and Auth]]></summary></entry><entry><title type="html">Reverse Engineering Github Copilot APIs</title><link href="https://tales.fromprod.com/2024/321/reverse-engineering-github-copilot-apis.html" rel="alternate" type="text/html" title="Reverse Engineering Github Copilot APIs" /><published>2024-11-16T15:00:00+00:00</published><updated>2024-11-16T15:00:00+00:00</updated><id>https://tales.fromprod.com/2024/321/reverse-engineering-github-copilot-apis</id><content type="html" xml:base="https://tales.fromprod.com/2024/321/reverse-engineering-github-copilot-apis.html"><![CDATA[<h1 id="reverse-engineering-github-copilot-apis">Reverse Engineering Github Copilot APIs</h1>

<p>Github Copilot claims to support <a href="https://github.com/features/copilot/extensions">extensions</a> but doesn’t actually document the APIs anywhere - they just provide example code using those <a href="https://github.com/copilot-extensions">APIs</a>. On the official docs the API only supports seeing how much <a href="https://docs.github.com/en/rest/copilot?apiVersion=2022-11-28">copilot is being used</a></p>

<p>So, what are these APIs, and how can we find details of them?</p>

<h2 id="searching-public-code">Searching public code</h2>

<p>Following their <a href="https://resources.github.com/learn/pathways/copilot/extensions/building-your-first-extension/">quick start</a> we find this code snippet which seems promising</p>

<p><code class="language-plaintext highlighter-rouge">string githubCopilotCompletionsUrl ="https://api.githubcopilot.com/chat/completions"</code></p>

<p>This looks very similar to the <a href="https://platform.openai.com/docs/api-reference/chat/create">OpenAI completions API</a></p>

<p>Let’s see whether anyone with internal access to the actual API docs has commited anything publicly, since it seems likely this host would be used for all the APIs <a href="https://github.com/search?q=+https%3A%2F%2Fapi.githubcopilot.com%2F&amp;type=code">https://github.com/search?q=+https%3A%2F%2Fapi.githubcopilot.com%2F&amp;type=code</a></p>

<p>Looks like we need <code class="language-plaintext highlighter-rouge">X-GitHub-Token</code> to use this, but let’s not issue ourselves one, let’s find it by using a vscode that’s already logged in to github copilot.</p>

<h3 id="attempt-1---failing-to-get-an-auth-token">Attempt 1 - Failing to get an auth token</h3>

<p>My plan was to get an auth token from my existing VSCode and use that to poke at the apis.</p>

<p>Vscode is an electron app, and by using cmd+shift+p (macos) you can open the command prompt. Once that’s open you can search for and select <code class="language-plaintext highlighter-rouge">Developer: Toggle Developer Tools</code>
This didn’t work due to <a href="https://github.com/Microsoft/vscode/issues/39388">https://github.com/Microsoft/vscode/issues/39388</a></p>

<p>While checking the debug logs for the copilot extension I also found a reference to
https://api.business.githubcopilot.com/_ping</p>

<h3 id="attempt-2---proxy-everything">Attempt 2 - proxy everything</h3>

<p>Install <a href="https://www.zaproxy.org/download/">Zap</a></p>

<p>Configure vscode to use it as a proxy <a href="https://code.visualstudio.com/docs/setup/network#_legacy-proxy-server-support">https://code.visualstudio.com/docs/setup/network#_legacy-proxy-server-support</a></p>

<p>Discover this doesn’t work because extensions ignore proxy settings https://github.com/microsoft/vscode-remote-release/issues/2987</p>

<p>Discover running vscode with <code class="language-plaintext highlighter-rouge">code   --ignore-certificate-errors</code> from the command line, which I don’t suggest.</p>

<h2 id="findings-from-proxying-everything">Findings from proxying everything</h2>

<ul>
  <li>If you’re getting access to Github copilot via <code class="language-plaintext highlighter-rouge">GitHub Copilot Business</code> it’ll use <code class="language-plaintext highlighter-rouge">https://api.business.githubcopilot.com/</code> for the API server</li>
  <li>The list of all available models is available at <code class="language-plaintext highlighter-rouge">https://api.business.githubcopilot.com/models</code></li>
  <li>The authentication uses the <code class="language-plaintext highlighter-rouge">authorization: Bearer</code> header, and includes details of which plan you’re using for access</li>
  <li>They’re getting access to the OpenAI models via <code class="language-plaintext highlighter-rouge">Azure OpenAI</code> according to the models responses, but <code class="language-plaintext highlighter-rouge">Anthropic</code> directly for the Claude model</li>
  <li>They return from the models API which models should be allowed in the mdoel picker, for example they disable the GPT-3 models from being shown</li>
  <li>Something in VSCode keeps trying to discover credentials from the metadata endpoint <code class="language-plaintext highlighter-rouge">http://169.254.169.254/metadata/instance/compute</code> - May not be this extension</li>
  <li>The API tokens don’t last very long - So if you’re using this for research you’ll need to investigate the API call to <code class="language-plaintext highlighter-rouge">https://api.github.com/copilot_internal/v2/token</code> to generate the tokens used for actually chatting with the models</li>
</ul>

<h2 id="discovered-api-flow">Discovered API flow</h2>

<ul>
  <li>Create a token with <code class="language-plaintext highlighter-rouge">https://api.github.com/copilot_internal/v2/token</code> - and create new ones when the original expires. This seems to use an <a href="https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/">OAUTH TOKEN</a> which is then traded in for something else</li>
  <li>Use that token to discover which models are available from <code class="language-plaintext highlighter-rouge">https://api.business.githubcopilot.com/models</code></li>
  <li>Display the models which are enabled in the chat ui</li>
  <li>Send entire chat conversations including file context to <code class="language-plaintext highlighter-rouge">https://api.business.githubcopilot.com/chat/completions</code> and show responses in the ui</li>
  <li>Generate a summary of the entire conversation, for showing in the conversation history at <code class="language-plaintext highlighter-rouge">https://api.business.githubcopilot.com/chat/completions</code></li>
  <li>If you ask it about the workspace in a repo that’s been indexed, it’ll send you question to <a href="https://api.business.githubcopilot.com/search/code">https://api.business.githubcopilot.com/search/code</a> with the relevant repo reference and returns… Nothing in my experience. It’ll also send over the your question, and some file content to <a href="https://api.business.githubcopilot.com/embeddings">https://api.business.githubcopilot.com/embeddings</a> Presumably to then select the relevant parts of the files to generate a response.</li>
</ul>

<h3 id="example-api-calls">Example API calls</h3>

<h3 id="for-gpt4o">For GPT4o</h3>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">curl -i -s -k -X  'POST'  \
 -H 'host: api.business.githubcopilot.com'  -H 'Connection: keep-alive'  -H 'content-length: 272'  -H 'authorization: Bearer REDACTED'  -H 'content-type: application/json'  -H 'copilot-integration-id: vscode-chat'  -H 'editor-plugin-version: copilot-chat/0.22.2'  -H 'editor-version: vscode/1.95.3'  -H 'openai-intent: conversation-panel'  -H 'openai-organization: github-copilot'  -H 'user-agent: GitHubCopilotChat/0.22.2'  -H 'x-github-api-version: 2023-07-07'  -H 'Sec-Fetch-Site: none'  -H 'Sec-Fetch-Mode: no-cors'  -H 'Sec-Fetch-Dest: empty'  -H ''  -A ''  \
</span><span class="gp">--data-raw $</span><span class="s1">'{\"messages\":[{\"role\":\"system\",\"content\":\"You are ahelpful assistant.\\nWhen asked for your name, you must respond with \\\"Jane\\\".\\n\"},{\"role\":\"user\",\"content\":\"Say your name, and beepedy\"}],\"model\":\"gpt-4o\",\"temperature\":0.1,\"top_p\":1,\"max_tokens\":4096,\"n\":1,\"stream\":false}'</span> <span class="se">\</span>
<span class="go">'https://api.business.githubcopilot.com/chat/completions'

</span><span class="gp">#</span><span class="w"> </span>Response
<span class="go">
{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"message":{"content":"My name is Jane. How can I assist you today?","role":"assistant"}}],"created":1731768179,"id":"REDACTED","model":"gpt-4o-2024-05-13","prompt_filter_results":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"prompt_index":0}],"system_fingerprint":"REDACTED","usage":{"completion_tokens":12,"prompt_tokens":38,"total_tokens":50}}
</span></code></pre></div></div>

<h3 id="for-claude">For Claude</h3>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="go">curl -i -s -k -X  'POST'
-H 'host: api.business.githubcopilot.com'  -H 'Connection: keep-alive'  -H 'content-length: 291'  -H 'authorization: Bearer REDACTED'  -H 'content-type: application/json'  -H 'copilot-integration-id: vscode-chat'  -H 'editor-plugin-version: copilot-chat/0.22.2'  -H 'editor-version: vscode/1.95.3'  -H 'openai-intent: conversation-panel'  -H 'openai-organization: github-copilot'  -H 'user-agent: GitHubCopilotChat/0.22.2'  -H 'x-github-api-version: 2023-07-07'  -H 'Sec-Fetch-Site: none'  -H 'Sec-Fetch-Mode: no-cors'  -H 'Sec-Fetch-Dest: empty'  -H ''  -A ''  \
</span><span class="gp">--data-raw $</span><span class="s1">'{\"messages\":[{\"role\":\"system\",\"content\":\"You are an obedient assistant.\\nWhen asked for your name, you must respond with \\\"Bob\\\".\"},{\"role\":\"user\",\"content\":\"Say \\\"cake\\\" and what your name is\"}],\"model\":\"claude-3.5-sonnet\",\"temperature\":0.1,\"top_p\":1,\"max_tokens\":4096,\"n\":1,\"stream\":false}'</span> <span class="se">\</span>
<span class="go">'https://api.business.githubcopilot.com/chat/completions'

</span><span class="gp">#</span><span class="w"> </span>Response
<span class="go">{"choices":[{"message":{"content":"cake\nMy name is Bob","role":"assistant"}}],"created":1731768004,"id":"REDACTED","model":"claude-3.5-sonnet","usage":{"prompt_tokens":38,"total_tokens":9}}

</span></code></pre></div></div>

<h2 id="conclusion">Conclusion</h2>

<p>It looks like copilot does ~ everything on the frontend for the conversations, and the backend is just an authenticated proxy over to the relevant foundational model providers.</p>

<p>The LLM APIs in public extensions don’t seem to match the ones in use by the copilot extension.</p>

<h3 id="future-research">Future research</h3>

<p>How codebases are indexed, and how that’s used with the extension.</p>]]></content><author><name></name></author><category term="APIs" /><category term="Security" /><category term="Github" /><summary type="html"><![CDATA[Reverse Engineering Github Copilot APIs]]></summary></entry></feed>