ソフトウェアエンジニアの新田です。
そうめんの季節ですね。唐船峡めんつゆにハマってます。
カンムでは、 EDA や機械学習モデルのトレーニング・評価のように、それなりの計算資源を要する分析・開発のために、 AWS の SageMaker AI Studio を使って Jupyter Notebook 環境をクラウドで用意する基盤を整えています。 メモリが要るなら大きめのインスタンスを、 GPU が要るなら GPU インスタンスを、用途に応じて数クリックで建てられます。 起動すれば JupyterLab の UI にアクセスでき、 Python の実行も、データウェアハウス BigQuery へのアクセスも、その環境下で完結します。
ここ一年で、 Claude Code や Codex といった AIエージェント は社内のエンジニアにとって当たり前の道具になりました。 ところが、Studio 上の JupyterLab で作業をするデータサイエンティストは、この恩恵を得られていませんでした。 JupyterLab はブラウザ越しのリモート IDE で、ローカル PC 上で動くAIエージェントからは操作できませんでした。
そこで、ローカルPCのAIエージェントから、Studio 上の Jupyter Notebook を直接操作できる仕組みを作りました。今回の記事ではその設計と実装についてお話しします。
- 1. Synapse Studio
- 2. SageMaker AI Studio とは
- 3. SageMaker AI Studio 標準の AI アシスタント ではなく MCP にした理由
- 4. なぜ JupyterLab を採用し続けたか
- 5. 人間とAIが同じ notebook を編集する仕組み
- 6. MCP tools の設計
- 7. リモートからの認証
- 8. Skill の役割
- 9. 人間向け WebUI
- 10. 使ってみて
- 11. おわりに
1. Synapse Studio
作ったものは大きく2つです。
- AI エージェント向けのツール群 (MCP サーバ)
- 人間向けの WebUI

どちらも、私のチームが内製しているツール基盤 Synapse の上に載せて作りました。以降この記事ではこれらの成果物を Synapse Studio と呼称します。
Synapse についてはメンバーの佐野さんが書いた以下の記事に解説があります。
AIエージェントは、リモート MCP サーバ Synapse Tools に接続し、 studio_* から始まるツール群を呼び出します。
これらは、 SageMaker AI Studio のリソースを操作したり、 JupyterLab Server を REST / Websocket 経由で操作したりして、
インスタンスの起動や終了、 Notebook の作成・編集、セルの実行・結果の取得などを行うことができます。
加えて、ツールを並べるだけでは足りないので、AIエージェント向けに Agent Skills を整備しています。
人間のデータサイエンティスト向けには、自分のインスタンスの起動状況の確認ができ、さらに JupyterLab の画面へ飛べる WebUI を用意しました。 仕組みは後述するのですが、ねらいは、AIエージェントと人間が同じ Jupyter Notebook を同時に編集できることです。
2. SageMaker AI Studio とは
本題に入る前に、ここから先で頻出する SageMaker AI Studio の用語をざっと整理しておきます。SageMaker AI Studio は、AWS が提供する機械学習向けの統合開発環境です。JupyterLab、Code Editor (VS Code 系)、RStudio などのアプリケーションをマネージドな AWS インフラ上で立ち上げられる、というのが大まかな立て付けです。
Amazon SageMaker Studio - Amazon SageMaker AI
カンムが Synapse Studio で使っているのは JupyterLab で、ここで登場する主要な概念は次の4つです。
- Domain — テナント単位。VPC / Subnet などの設定はこの単位に紐づきます。
- UserProfile — ユーザ単位。Domain の中に複数作ります。各 UserProfile には個別の EFS access point が割り当てられます。
- Space — 作業領域の単位。UserProfile が複数の Space を所有できます。Space は instance type (
ml.m5.xlarge/ml.g5.xlarge等) と EBS を持ち、ここに App が乗ります。 - App — Space 上で実際に動くプロセス。本記事の文脈では JupyterLab App のことを指します。1 Space あたり JupyterLab App は1つです。Space を残したまま App だけを stop / start すれば、計算課金は止めつつ EBS と EFS の中身は次回起動時に引き継げます。

イメージとしては、Domain がテナント、UserProfile がユーザ、Space がワークスペース、App がそのワークスペース上で動かす JupyterServer プロセス、と捉えるとそんなに大きくは外しません。以降の章では、この4つの概念を前提に話を進めます。
3. SageMaker AI Studio 標準の AI アシスタント ではなく MCP にした理由
SageMaker AI Studio には、JupyterLab 上で AI アシスタント を利用する標準機能があります。たとえば Amazon Q Developer、Jupyter AI、Agent Context Protocol 対応のコーディングアシスタントにより、JupyterLab 内でコード生成・説明・デバッグ支援を受けられます。
- Amazon Q Developer - Amazon SageMaker AI
- SageMaker ノートブック環境の生成 AI - Amazon SageMaker AI
- Using a coding assistant to expedite your machine learning workflows - Amazon SageMaker AI
それでも MCP として内製したのは、私たちの作業の起点がすでに手元のPCで動かす AI エージェント側(claude code / codex)にあったためです。これら agent には Git リポジトリだけでなく、Notion MCP や Synapse MCP の他 tools も接続されており、開発タスク、設計メモ、ドキュメント、社内コンテキストを同じ場所から扱えます。notebook 作業だけを JupyterLab 内の別アシスタントに切り替えると、この文脈が分断されます。
また、特定のアシスタントに閉じたくなかったことも理由です。そこで notebook の編集・実行だけでなく、Space の作成や JupyterLab App の起動・停止といった管理操作も MCP server として切り出し、Claude Code、Codex、その他の agent から同じインターフェイスで扱えるようにしました。つまり今回の仕組みは、JupyterLab の中で AI を使うためではなく、手元の AI エージェントから SageMaker Studio / JupyterLab を外部 tool として扱うためのものです。
4. なぜ JupyterLab を採用し続けたか
近年、ソフトウェア開発における AI エージェントの利用は急速に広がっています。少なくとも筆者自身や筆者の観測範囲では、Claude Code や Codex にコーディングさせ、人間が diff をレビューする、という開発スタイルが日常的になりつつあります。
データサイエンティストが JupyterLab を開いて自分で Python や SQL を書きながら EDA や ML モデル開発を進める、という従来の体験についても、おそらく同じ流れが来ます。 AI エージェントに書いてもらうことで、データサイエンティスト自身はコードを書かなくなるだろう、と昨今の現状を見ると考えられます。
そうなると、JupyterLab というインターフェイスにこだわる強い理由はだんだん弱まっていきます。 コードセルを順に実行し結果を確認しながら探索的にコードを書き進めるという、REPL ベースのインタラクティブな操作をベースに、 JupyterLab はあくまでも人向けのインターフェイスとしてリッチなIDEとして拡張してきました。 AI エージェントが分析・開発を主導するなら、ここに無理にエージェントを乗せなくても、もっとエージェント前提のインターフェイスを別途立てる選択肢もあったわけです。
それでも今回あえて選んだのは、開発インターフェイスは JupyterLab のままにし、さらに人間と AI エージェントが同時に編集できる協調編集の構造にする、という設計でした。 これは未来の予測にあまり踏み込まずに「人間にもノートブックを編集できる余地を残す」 ことを意図的に選んだということでもあります。

理由は主に2つあります。
1つ目は、分析・開発の仕事は普通のソフトウェアのコーディングとは性質が違う、という見立てです。 データ分析・MLモデル開発の過程では、コードの正しさだけでは判断できない場面が多くあります。分布の歪みを外れ値と見るべきか、母集団の特徴と見るべきか。ある特徴量を採用すべきかどうか。こうした判断には、ドメイン知識や分析目的の理解が不可欠です。 コードを書く工程だけ取り出せばエージェントが優れた出力を生成するかもしれませんが、そこでの判断を最後までエージェントに委ね切れるかは、まだはっきりとは言えません。 そのため、現時点では AI エージェントが notebook を編集・実行できるようにしつつ、人間も同じ notebook に介入できる余地を残す設計が望ましいと判断しました。
2つ目は、弊社のデータサイエンティストにとっての開発体験の連続性です。 JupyterLab というインターフェイスを完全に置き換えると、これまでの作業フローなど、いろいろなものを一度に変えてもらうことになります。 AI エージェント支援の恩恵を感じてもらうために、その手前で離脱されてしまっては元も子もありません。 そこで、まずは今までと同じ JupyterLab を開き、これまでと同じように自分でセルを書ける状態を残しつつ、 横で Claude Code や Codex がセルを編集・実行してくれる、という形を取りました。 普段の作業の延長線上で AI エージェントの恩恵を感じやすい、というのが体験設計上の意図です。
この2つの理由はどちらも「現時点ではこの形がいい」 という消極的な選択ではあります。 AI エージェントが Data/ML 開発全工程まで優れた仕事を任せられるようになり、 データサイエンティスト側のワークフローもそれに最適化された別のインターフェイスに移っていく未来は、おそらくそう遠くないうちに来ます。 そのときには Synapse Studio の形ももう一度考え直すことになるはずですが、 今は「人間に編集の余地を残しつつ、AI エージェントが本気で並走できる JupyterLab」 という地点に置いておくことにしました。
ここまでが、なぜ JupyterLab を残したかという設計判断です。 ただし、本当に難しいのはここからです。人間がブラウザで開いている notebook と、ローカル AI エージェントが編集する notebook を、同じ live state として扱わなければなりません。 単に .ipynb ファイルを書き換えるだけでは、ブラウザ上の編集中状態と衝突してしまいます。
次の章では、この問題を避けるために、Synapse Studio が JupyterLab の協調編集 room に参加し、live YDoc を直接操作する仕組みを説明します。
5. 人間とAIが同じ notebook を編集する仕組み
ここからが本記事の核心である、人間と AI エージェントが同じ notebook を同時に編集できる仕組みの話です。実装に入る前に、そもそも JupyterLab がどのように notebook を扱っているかを2段階で押さえておきます。
Jupyter Server が notebook 操作のハブになる
Jupyter は大きく3つの登場人物でできています。ブラウザ上で動く frontend、その裏側で動く Jupyter Server、そして実際に Python などのコードを走らせる kernel です。
重要なのは、この frontend / kernel / ディスク上の .ipynb ファイル が互いに直接は通信せず、すべて Jupyter Server をハブにしてやり取りする、という点です。
ブラウザ上でユーザが保存操作をすると、その内容はブラウザから Jupyter Server に送られ、Server 側の ContentsManager が .ipynb を JSON ファイルとして書き出します。
一方 kernel はそもそも notebook という単位を認識しておらず、送られてきた cell のコードを実行して結果を返すだけで、ファイルの保存には一切関与しません (Server が保存を担っているからこそ、その言語の kernel が無くても notebook を開いて編集できます)。
通信経路も役割ごとに分かれていて、ブラウザと Server の間は REST と WebSocket、Server と kernel の間は ZeroMQ です。 kernel が ZeroMQ で吐き出す実行結果は、Server が WebSocket 側に中継してブラウザへ届けます。

要するに、ファイルへの永続化は Server の担当という構造です。 この「Server が間に立つ」前提が、次の協調編集の話につながります。
Architecture — Jupyter Documentation 4.1.1 alpha documentation
JupyterLab に協調編集が組み込まれるまで
JupyterLab はもともと「1ユーザが1つの notebook を開いて手で編集する」ことを前提に設計されていました。同じファイルを2人で開くと、どちらかが保存した瞬間にもう一方の編集中の内容が消える、というオーソドックスな挙動です。 これを CRDT ベースの協調編集に乗せ替えていく試みは、長い時間をかけて進められてきました。
JupyterLab 3.x の時代には、Real-Time Collaboration (RTC) が JupyterLab 本体に experimental な機能として組み込まれていました (jupyter lab --collaborative で有効化する形です)。動きはするものの実験段階で、production 利用は推奨されない状態でした。
JupyterLab 4.0 (2023年5月) から、RTC はコアから独立した別パッケージ jupyter-collaboration に切り出されました。同パッケージの 1.0 もこのタイミングで release されています。
Real Time Collaboration — JupyterLab 4.0.13 documentation
jupyter-collaboration は Jupyter Server extension と JupyterLab の frontend extension の組合せで、内部では jupyter_ydoc を共通スキーマとして使います。
jupyter_ydoc は Jupyter のドキュメント型 (YNotebook / YUnicode / YBlob) を CRDT データ構造として定義したパッケージで、JavaScript 側には対応する @jupyter/ydoc があります。
jupyter_ydoc は Python 側で pycrdt、JavaScript 側で Yjs を使います。pycrdt は Yjs の Rust 実装である yrs の Python binding で、Yjs と同じ binary wire protocol (lib0 encoding) を喋ります。
つまり、ブラウザの JupyterLab (Yjs) とサーバの Python (pycrdt) が、同一の CRDT ドキュメントを WebSocket 越しに同期できる、というのが基本的な動きです。

ただし、この jupyter-collaboration の素直な作りには弱点もあります。 1つの notebook の中身が丸ごと、つまり cell のコードだけでなく実行結果の outputs まで単一の YDoc に乗り、サーバはそれをメモリ上に抱えたまま全 peer と同期し続けます。 EDA や ML の notebook は画像・大きな表・学習ログといった重い outputs を溜めがちなので、YDoc がそのぶん肥大化し、開くのが遅い・メモリを食う・同期が重い、といった問題が出やすくなります。
この弱点に対しては、より新しいアプローチを試す動きもあります。
その1つが jupyter-server-documents で、ドキュメントの扱いをさらにサーバ側へ寄せた実験的なエンジンです。
これは jupyter-ai-contrib という org で開発されています。jupyter-ai-contrib は「人間と AI が Jupyter 上で協調するための部品」をテーマに Jupyter のコミュニティ有志が立ち上げた org で、Jupyter AI (jupyterlab/jupyter-ai) の v3 で codebase を分割した流れから生まれたものです。
(ここで強調しておきたいのは、これは Jupyter 公式に採用された標準ではなく、本記事執筆時点でも 0.x で活発に開発が続いている段階だ、という点です。)

技術的な位置づけとしては、jupyter-collaboration のサーバ側実装を置き換えるものです。
肝になるのが outputs の持ち方です。
cell の本体は YDoc で扱う一方、重くなりがちな outputs は YDoc に載せません。
outputs は OutputsManager というレイヤが別管理し、YDoc 側には URL 参照だけを持つ最小の placeholder を置くことで CRDT ドキュメントを軽く保ちます。
実体の output は {file_id}/{cell_id}/{index}.output という単位でサーバ側のファイルに退避され、frontend は GET /api/outputs/{file_id}/{cell_id}/{index}.output を叩いて必要なときだけ取得します。
これで、大量の出力を持つ notebook でもメモリ消費や読み込み速度を抑えられる、というわけです。

AI エージェントを協調編集 room の peer にする
Synapse Studio が「もう1人の参加者」として乗り込むのは、まさにこの room です。
先に見た通り Contents API が扱うのは、Jupyter Server が永続化した .ipynb ファイルの snapshot です。一方、ブラウザ上で編集中の notebook の現在状態は、協調編集エンジンが保持する live な YDoc として存在します。
AI エージェントが Contents API 経由で .ipynb を更新しても、その変更は CRDT merge に乗りません。人間がブラウザで編集中の内容とは別系統の変更として扱われ、最終的にはどちらかの保存結果が他方を上書きしてしまいます。
この問題を避けるため、Synapse Studio では .ipynb ファイルそのものではなく、JupyterLab が利用している live な YDoc に直接接続します。AI エージェントを協調編集 room の peer として参加させることで、人間と AI の編集を同じ CRDT 上の変更として扱えるようにしています。

具体的には、ツールが呼ばれるたびに次のことをします。
POST /api/fileid/index?path=<notebook_path>で notebook のfile_idを取得する。これは同じ path に対して常に同じ UUID を返す idempotent な API です。wss://.../api/collaboration/room/json:notebook:<file_id>に WebSocket で接続し、SyncStep1 / SyncStep2 を双方向にやり取りする handshake で、サーバ側 YDoc の最新状態を手元のpycrdt.Docに取り込む。- 取り込んだ
jupyter_ydoc.YNotebookをpycrdtの API で直接 mutate する。たとえば cell の文字列置換なら、その cell のYTextの対象範囲をdelしてinsertし直すだけです。CRDT の更新なので、同じ瞬間に人間が別の場所を触っていても、双方の編集が保たれます。 - 編集が終わったら、handshake 直後に控えておいた state からの差分だけを計算し、
SYNC_UPDATEとしてサーバに送って切断する。あとはサーバ側の jupyter-server-documents が、その更新をブラウザなど他の peer に転送し、頃合いを見て.ipynbに書き出してくれます。
この方式のいちばんの利点は、「ファイルをいつ・どう書き戻すか」をツール側で一切考えなくてよいことです。ここまで見てきたとおり、保存も衝突解決ももともとサーバ側 (jupyter-server-documents) の仕事なので、そこに相乗りしてしまえば、人間がブラウザで同じ notebook を開いていても編集が消えることはありません。
読み取りも同じ room を使います。studio_get_notebook のような「現在の cells を読む」ツールは、REST の GET /api/contents ではなく、やはり WS で handshake して YDoc の snapshot を直接読みます。.ipynb は YDoc から時々書き出される派生物なので、編集直後に REST で読むと、まだ書き出されていない古い snapshot を掴んでしまう window があるためです。live な YDoc を直接読めば、このラグを避けられます。
もうひとつ、handshake の直後に y-protocols の awareness 更新を1回だけ送り、JupyterLab の画面右上にある participants 一覧に「AI」というアバターを約30秒間表示させています。 データサイエンティストが画面を見ているときに「いま AI が同じ notebook を触っている」と分かります。

6. MCP tools の設計
前章では、Synapse Studio が JupyterLab の協調編集 room に参加し、live YDoc を直接操作する仕組みを説明しました。この章では、その仕組みを AI エージェントから扱うために、MCP tools としてどのような interface に切り出したかを説明します。 以下は AI エージェントに提供するMCP tool 群の一部です。
| 役割 | 主な tool |
|---|---|
| UserProfile 自己登録 | studio_register_self |
| Space ライフサイクル | studio_list_spaces / studio_create_space / studio_get_space / studio_delete_space / studio_update_space |
| JupyterLab App ライフサイクル | studio_start_app / studio_stop_app |
| Notebook ファイル CRUD | studio_list_notebooks / studio_get_notebook / studio_create_notebook / studio_delete_notebook / studio_rename_notebook / studio_copy_notebook |
| Cell 編集 | studio_insert_cell / studio_overwrite_cell_source / studio_edit_cell_source / studio_delete_cell |
| Cell 実行と Kernel | studio_execute_cell / studio_get_execution_status / studio_list_kernels / studio_get_kernel / studio_restart_kernel / studio_interrupt_kernel / studio_shutdown_kernel |
| ターミナル実行 | studio_run_in_terminal |
ML モデルのトレーニングなど、 cell 1 つの実行に数十分〜数時間かかるようなワークロードでは、 LLM の同期 tool 呼び出しで完了まで待たせるわけにはいきません。 そこで投入系と進捗 polling 系の tool を分け、 投入系は「処理を開始した」 ことを即座に返し、 完了は別 tool で取り直す形に揃えています。 (なお、MCPの最新仕様ではこの非同期処理パターン向けにTasksが整備されていますが、claude codeやcodexなどの多くのMCPクライアントは未対応なので、今回はTasksを採用できずにナイーブな実装にしています)
たとえばエージェントが「analysis.ipynb の3番目のセルを書き換えて実行して、結果を要約して」と頼まれたときの典型的な流れは次のようになります。
studio_list_spacesで自分の Space と App の状態を確認する。App がInService(= 起動完了状態) でなければstudio_start_appを投入し、studio_get_spaceで立ち上がりを polling する。studio_get_notebook(space, "user-default-efs/notebooks/analysis.ipynb")で現状の cells と、 各 cell の位置cell_index/ uuidcell_idを取得する。studio_edit_cell_source(...)またはstudio_overwrite_cell_source(...)でセルの中身を書き換える。studio_execute_cell(...)で実行を投入する。 戻り値には後続の polling で使う識別子 (実行を担うkernel_id、 notebook ファイルの内部 IDfile_id、 対象 cell のcell_id、 投入直前の実行カウンタsubmitted_execution_count) が含まれる。studio_get_execution_status(...)にこれらを渡して進捗を取り直す。 戻り値には kernel の現在状態 (busy/idle/dead)、 完了済みセルのexecution_count、 ここまでの partial outputs、 そして「次の poll までこれくらい待つと良い」 という推奨間隔next_poll_after_secondsが乗っており、statusがcompletedになるまでこの間隔で繰り返す。studio_get_notebook(...)をもう一度叩いて outputs を含む最終 snapshot を取得する。

この流れで少し厄介なのが、kernel の実行完了と notebook への結果反映が完全には同時ではないことです。
cell の実行自体は、kernel と通信する WebSocket に execute_request を送り、iopub に流れてくる結果を jupyter-server-documents の OutputsManager が拾って永続化する、という流れで進みます。
そのため、kernel が idle に戻った瞬間と、結果が notebook 側の placeholder file に反映される瞬間には、短い時間差が起きることがあります。このズレを見分けるために、studio_execute_cell は投入直前の cell の execution_count を submitted_execution_count として控えて返します。

kernel が idle でも execution_count がその控えから動いていなければ、まだ結果が notebook 側に反映されていない可能性があります。
kernel は終わっているのに、execution_count は投入時の 11 のままで、outputs もまだ空です。結果の書き戻し、つまり placeholder file への反映が追いついていない状態で、ここで idle だけを見て完了と早合点すると、空の、あるいは古い結果を掴みます。
そこで studio_get_execution_status は、kernel の状態だけでなく、execution_count や outputs の反映状況も見て、完了したかどうかを判断します。placeholder file への反映がまだ追いついていない場合は、status を completed ではなく pending のまま返し、次の polling で再確認できるようにしています。
もちろん、戻り値だけでは見分けがつかない厄介なケースもあります。たとえば cell の実行中に kernel プロセスが OOM Killer に殺されても、Jupyter サーバ側はそれを検知できず、kernel を生きていると思い続け、pending を返し続けることがあります。こうしたケースへの切り分け手順は Agent Skills 側にまとめておき、エージェントが必要に応じて参照して対処する形にしています。
もう1つの工夫として、各 tool のレスポンスには hints というフィールドを持たせています。これは実行結果そのものではなく、エージェントに次の行動を促すための短いメッセージです。
たとえば、先ほどの placeholder file への反映が kernel に追いついていないケースでは、studio_get_execution_status は status を pending のまま返しつつ、次のような hints を返します。
{ "status": "pending", "kernel_state": "idle", "execution_count": 11, "outputs": [], "next_poll_after_seconds": 3, "hints": [ "Execution is still in progress. Poll again after next_poll_after_seconds. To observe streaming output for long-running cells, call studio_get_notebook in parallel.", "Cell's execution_count (11) has not advanced from submitted_execution_count (11); the placeholder file is likely lagging the kernel. Retry." ] }
この hints により、エージェントは「kernel は idle だが、placeholder file がまだ追いついていないので、もう一度 poll すればよい」と判断できます。単に pending を返すだけだと、長時間実行中なのか、結果反映待ちなのか、あるいは異常系なのかが区別しづらいため、次に取るべき行動を tool 側から明示しています。
同じ考え方で、kernel が dead になっていれば再起動を促す、App がまだ起動中なら少し待って再取得する、notebook の cell_id が見つからなければ studio_get_notebook で最新の cell 一覧を取り直す、といった誘導も hints に載せています。これにより、エージェントが JupyterLab や SageMaker Studio の内部状態を完全に理解していなくても、失敗時のリカバリをある程度自律的に進められるようにしています。
7. リモートからの認証
ここまでは「Synapse がどのプロトコルで Jupyter を操作するか」という話でした。ここからはその手前、AIエージェントから Synapse を経由して Studio の中の Jupyter にたどり着くまでを、実際にどう認証して通信しているのか、順に追っていきます。
- クライアントから Synapse までは、Entra ID のアクセストークンを用います。
- synapse から Jupyter までは、 SageMaker サービスが発行する署名付きURLを踏んで Cookie セッションを確立する、というやり方になります (どちらも通信自体は HTTPS の上です)。

認証・認可
Synapse MCP サーバは EntraID を IdP として統合しています。
今回の Synapse Studio 機能 (studio_* ツール) の利用は申請制にしており、申請者には StudioUser というロール(AppRole)を EntraID 側で付与します。
MCP サーバ内部では認証後に roles クレームを検証し StudioUser が付与されている場合のみツールの実行を許可します。
SageMaker AI Studio の UserProfile を認証したユーザ一人一人に対応させます。
アクセストークンに乗る oid クレームが UserProfile の Name に対応させています。

非公開の Jupyter にセッションを張る
続けて Synapse から SageMaker Studio サービス上の Jupyter にどう到達するか、です。
Studio の JupyterLab へのアクセス方法は、 SageMaker サービスの CreatePresignedDomainUrl という API を呼ぶと、指定した UserProfile としてサインインできる 署名付きURL が返り、ブラウザでそれにアクセスすると対象の App (ここでは JupyterLab) に着地します。
これは実際にAWSコンソールからアクセスする場合も同様の仕組みになります。
Synapse も同様に、この 署名付きURL を API からプログラムで踏んでいます。
署名付き URL を session で踏んで Cookie を持たせ、その session で Jupyter の API を叩くこと自体は、aws-samples などにも採用例がある方法です。
1回のツール呼び出しの内側で起きているのは、次の流れです。
CreatePresignedDomainUrlで AuthorizedUrl を発行する。URL 自体の有効期間となるTTL (ExpiresInSeconds) を 60 秒に縮めて漏洩時のウィンドウを狭めつつ、そこから確立されるセッションのTTL (SessionExpirationDurationInSeconds) も同様に縮めています。httpxのクライアントで AuthorizedUrl を踏み、Cookie セッションを確立する。- 確立したセッションの
_xsrfcookie をX-XSRFTokenヘッダに転記する。Jupyter の REST は POST / PUT / DELETE でこれが無いと弾くため、ここで載せておく。

コードにするとおおよそ次のようになります。
@contextlib.asynccontextmanager async def jupyter_session(user_profile_name, space_name): # 1. DescribeSpace で Space 固有の JupyterLab base URL を取る space = await asyncio.to_thread(_describe_space_sync, space_name) base_url = space["Url"] # 2. AuthorizedUrl を発行 authorized_url = await asyncio.to_thread( _create_presigned_url_sync, user_profile_name, space_name, None ) # 3. AuthorizedUrl を踏んで Cookie 認証を確立 async with httpx.AsyncClient(follow_redirects=True) as client: await client.get(authorized_url) # 4. _xsrf cookie を X-XSRFToken header に転記 client.headers.update({"X-XSRFToken": client.cookies["_xsrf"]}) yield client, base_url
確立したセッションを持ち回さず、ツール呼び出し1回ごとに新しく張って、ツールが終わったら捨てる、という使い捨てにしています。
CreatePresignedDomainUrl には AWS 側で毎秒1リクエストのクォータがあって、ツールを連発するとここに当たり得ます。それでもセッションを長く持ち回すより、毎回新鮮な Cookie で叩くほうが、漏洩時のウィンドウも狭く、セッション管理も不要で済みます。我々の規模では実運用でクォータに当たることはほとんどないのでこの設計にしています。
漏洩対策はもう1段あります。CreatePresignedDomainUrl の認可に、IAM の条件で送信元を縛っています。AWS のドキュメントにあるとおり、この条件は URL の発行時だけでなく、発行後に URL を踏んで JupyterServer に到達するリダイレクトのタイミングや、セッション中の全 HTTP リクエスト・WebSocket フレームに対しても評価されます。つまり URL やセッション cookie が漏れても、許可した送信元の外から踏まれたリクエストは 403 Forbidden で弾かれます。
Once the presigned URL is created, no additional permission is required to access this URL. IAM authorization policies for this API are also enforced for every HTTP request and WebSocket frame that attempts to connect to the app.
CreatePresignedDomainUrl - Amazon SageMaker
経路によって乗ってくる送信元の情報が違うので、許可は2系統に分けています。バックエンドからの発行は SageMaker 用の VPC エンドポイント (aws:SourceVpce) に、人がブラウザで開く経路は社内端末の WARP の egress IP (aws:SourceIp) に限定します。ここでいう WARP は、社内端末からの通信を Cloudflare WARP 経由にし、固定の egress IP から出ていくようにしている社内ネットワーク経路です。
Statement = [ # (1) バックエンド経由: SageMaker 用 VPC エンドポイントに限定 { Sid = "AllowCreatePresignedDomainUrlFromVpce" Effect = "Allow" Action = "sagemaker:CreatePresignedDomainUrl" Resource = "*" Condition = { StringEquals = { "aws:SourceVpce" = [ aws_vpc_endpoint.sagemaker["api"].id, aws_vpc_endpoint.sagemaker["studio"].id, ] } } }, # (2) ブラウザ経由: 社内端末の WARP egress IP に限定 { Sid = "AllowCreatePresignedDomainUrlFromWarp" Effect = "Allow" Action = "sagemaker:CreatePresignedDomainUrl" Resource = "*" Condition = { IpAddress = { "aws:SourceIp" = local.warp_egress_ip } } }, ]
TTL とネットワーク制限を適用した二段構えを行なっています。
8. Skill の役割
ここまでツールの話をしてきましたが、ツールを並べただけではエージェントはうまく動けません。ツールが提供するのは「Space を起動する」「セルを実行する」といった個々の能力で、それぞれの引数や戻り値は各ツールの description に書いてあります。 ですが実際の作業は、どのツールをどの順で呼ぶか、エラーが返ったらどう切り分けて復帰するか、やってはいけないことは何か、といった手順や判断の積み重ねで、これは1つ1つのツールの説明には収まりません。
そこを埋めるのが、エージェント向けの Skill です。 Skill をインストールしたエージェントはまずセッションの最初に前提 (MCP への接続、標準ワークフローの読み込み、UserProfile の登録) を済ませ、以降は「操作に入る前に、その状況に対応する手順書を読んでから動く」という形で作業を進めます。
手順書には、たとえば GPU を使用するときの落とし穴、長時間セルが pending のまま進まないときに OOM が発生しているかどうかを切り分ける手順、プライベートgitリポジトリを clone するときの GitHub 認証手順、ローカルと Space の間でファイルを受け渡す方法、といったテーマ別の知識を置いています。
エージェントがセッション開始時の記憶だけを頼りに動くと、思い込みで動かないコードを書いたり、学習中のセルを動いていないと判断して kill してしまったり、ということが実際に起きました。 そこで Skill 側は「覚えている気がしても、その場で対応する手順書を読み直してから動く」を強く促す作りにしています。
「入口の SKILL.md を薄くして、手順は references/ 以下に分ける」という構成自体は、Anthropic が公開している Skill のベストプラクティスどおりで、特別なことではありません。
今回それより意識したのは、references/ を「いつ読ませるか」でした。
エージェントは多くの場合、セッションの最初に Skill を読み込みます。
このとき手順書まで一緒に読み込ませてしまうと、セッションが長くなるにつれて、いざ必要な場面ではその内容が薄れてしまいます。コンテキストが伸びるほど前に読み込んだ情報が効きにくくなる、context rot と呼ばれる現象です。
そこで SKILL.md 側には手順そのものを載せず、「その状況になったら、対応する手順書をその場で読み直してね」とだけ書いています。
実際、振り分け表の冒頭はこうなっています。
## Pre-action lookup ⚠ CRITICAL — Before initiating any operation against a Space, or before hypothesising a root cause for a tool error, scan the cases below and Read the linked reference *in this turn* — even if you believe you already know it. Only then act. - `status="pending"` with no progress / suspected OOM → references/long-running-cells.md - Calling BigQuery / GCS from a notebook → references/gcp-from-studio.md - ...

正直なところ、この「その場で読み直させる」が長いセッションでどれだけ効いているかは、まだ検証できていません。 Skill の eval は、現実的なタスクを1件ずつ与えて、期待する振る舞い (どのツールをどう呼び、どの手順書を読むか) を書いた rubric と突き合わせて採点する形で回しています。 ただ、どれも会話を新規に始めた単発シナリオで、肝心の「長く話すと前に読んだものが薄れる」状況そのものは、まだ eval で再現できていません。 ここは今後ちゃんと測りたい宿題です。
9. 人間向け WebUI
ここまではエージェント経路の話でしたが、人間のデータサイエンティスト向けの入り口も用意しています。 Synapse の Web UI に Studio のページがあり、自分が owner (UserProfile) の Space の一覧と、それぞれの起動状況 (App が動いているかどうか) が見られます。 各 Space には「JupyterLab を開く」があり、押すとブラウザの新しいタブで Studio の JupyterLab が立ち上がります。

この WebUI も MCP サーバと同じ Entra ID アプリケーションによる認証を統合しているため EntraID oid で自分自身の UserProfile を解決することで、一覧に表示されるのは自分の Space だけです (StudioUser role が無い人には、代わりに利用申請とセットアップの案内が出ます)。
この「JupyterLab を開く」リンクは、前述の 署名付き URL の仕組みをそのまま人間側に向けたものです。署名付き URL を発行してブラウザリダイレクトします。
10. 使ってみて
Synapse Studio が無かった頃は、ローカルの Claude Code に分析を手伝ってもらうたびに「.ipynb をローカルで編集 → GitHub に push → Studio で git pull して実行 → 結果を git に戻してローカルへ」という往復が必要でした。データサイエンティストからは「これなら LLM を使わないほうが早いのでは」という声もありました。Synapse Studio はこの往復をまるごと無くし、ローカルのエージェントが Studio の JupyterLab を直接操作して実行結果もそのまま読み取れるので、人間は同じ notebook を隣で見ながら進められます。
もう一つ例を挙げます。とある不正検知モデルの評価にクロスバリデーションを採用したいとなったとき、どんな切り方のバリデーションが妥当かを検討する必要が出てきました。その候補出しから実装まで、エージェントと一緒に進められます。どう検証するのが妥当かというコードの正しさとは別の判断は人間が握りつつ、同じ notebook を隣で触りながらこうしたやり取りができるのが、この仕組みの効きどころです。
11. おわりに
今回作ったものを一言でまとめると、人間が使う JupyterLab はそのままに、ローカルの Claude Code / Codex を「同じ notebook のもう1人の編集者」として相乗りさせた仕組み、ということになります。
振り返ってみると、その核になる難所はほとんど自前で発明していません。notebook の保存と衝突解決は Jupyter Server と CRDT の room に、Studio への認証は SageMaker の presigned URL に、それぞれ既存のメカニズムへ相乗りしました。 自前で書いたのは、それらを繋ぐツール層と、エージェントを動かす Skill の中身です。おかげで、思ったより少ない実装で組み上がりました。

人間に編集の余地を残す形を選んだのは、強い思想というよりは今の時点の選択です。今後 JupyterLab というインターフェイス自体を見直すことになるかもしれません。 この前提が変わったときに、また作り直そうと思います。
おわり